mirror of
https://github.com/home-assistant/core.git
synced 2026-02-28 04:51:41 +01:00
Compare commits
1 Commits
edenhaus-t
...
homvolt_se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2cb3928e9 |
129
.github/actions/builder/generic/action.yml
vendored
129
.github/actions/builder/generic/action.yml
vendored
@@ -1,129 +0,0 @@
|
||||
name: "Image builder"
|
||||
description: "Build a Docker image"
|
||||
inputs:
|
||||
base-image:
|
||||
description: "Base image to use for the build"
|
||||
required: true
|
||||
# example: 'ghcr.io/home-assistant/amd64-homeassistant-base:2024.6.0'
|
||||
tags:
|
||||
description: "Tag(s) for the built image (can be multiline for multiple tags)"
|
||||
required: true
|
||||
# example: 'ghcr.io/home-assistant/amd64-homeassistant:2026.2.0' or multiline for multiple tags
|
||||
arch:
|
||||
description: "Architecture for the build (used for default labels)"
|
||||
required: true
|
||||
# example: 'amd64'
|
||||
version:
|
||||
description: "Version for the build (used for default labels)"
|
||||
required: true
|
||||
# example: '2026.2.0'
|
||||
dockerfile:
|
||||
description: "Path to the Dockerfile to build"
|
||||
required: true
|
||||
# example: './Dockerfile'
|
||||
cosign-base-identity:
|
||||
description: "Certificate identity regexp for base image verification"
|
||||
required: true
|
||||
# example: 'https://github.com/home-assistant/docker/.*'
|
||||
additional-labels:
|
||||
description: "Additional labels to add to the built image (merged with default labels)"
|
||||
required: false
|
||||
default: ""
|
||||
# example: 'custom.label=value'
|
||||
push:
|
||||
description: "Whether to push the image to the registry"
|
||||
required: false
|
||||
default: "true"
|
||||
# example: 'true' or 'false'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Verify base image signature
|
||||
shell: bash
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "${INPUTS_COSIGN_BASE_IDENTITY}" \
|
||||
"${INPUTS_BASE_IMAGE}"
|
||||
env:
|
||||
INPUTS_COSIGN_BASE_IDENTITY: ${{ inputs.cosign-base-identity }}
|
||||
INPUTS_BASE_IMAGE: ${{ inputs.base-image }}
|
||||
|
||||
- name: Verify cache image signature
|
||||
id: cache
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
|
||||
"ghcr.io/home-assistant/${INPUTS_ARCH}-homeassistant:latest"
|
||||
env:
|
||||
INPUTS_ARCH: ${{ inputs.arch }}
|
||||
|
||||
- name: Prepare labels
|
||||
id: labels
|
||||
shell: bash
|
||||
run: |
|
||||
# Generate creation timestamp
|
||||
CREATED=$(date --rfc-3339=seconds --utc)
|
||||
|
||||
# Build default labels array
|
||||
LABELS=(
|
||||
"io.hass.arch=${INPUTS_ARCH}"
|
||||
"io.hass.version=${INPUTS_VERSION}"
|
||||
"org.opencontainers.image.created=${CREATED}"
|
||||
"org.opencontainers.image.version=${INPUTS_VERSION}"
|
||||
)
|
||||
|
||||
# Append additional labels if provided
|
||||
if [ -n "${INPUTS_ADDITIONAL_LABELS}" ]; then
|
||||
while IFS= read -r label; do
|
||||
[ -n "$label" ] && LABELS+=("$label")
|
||||
done <<< "${INPUTS_ADDITIONAL_LABELS}"
|
||||
fi
|
||||
|
||||
# Output the combined labels using EOF delimiter for multiline
|
||||
{
|
||||
echo 'result<<EOF'
|
||||
printf '%s\n' "${LABELS[@]}"
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
INPUTS_ARCH: ${{ inputs.arch }}
|
||||
INPUTS_VERSION: ${{ inputs.version }}
|
||||
INPUTS_ADDITIONAL_LABELS: ${{ inputs.additional-labels }}
|
||||
|
||||
- name: Build base image
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ${{ inputs.dockerfile }}
|
||||
push: ${{ inputs.push }}
|
||||
cache-from: ${{ steps.cache.outcome == 'success' && format('ghcr.io/home-assistant/{0}-homeassistant:latest', inputs.arch) || '' }}
|
||||
build-args: |
|
||||
BUILD_FROM=${{ inputs.base-image }}
|
||||
tags: ${{ inputs.tags }}
|
||||
outputs: type=image,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
|
||||
labels: ${{ steps.labels.outputs.result }}
|
||||
|
||||
- name: Sign image
|
||||
if: ${{ inputs.push == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
# Sign each tag
|
||||
while IFS= read -r tag; do
|
||||
[ -n "$tag" ] && cosign sign --yes "${tag}@${STEPS_BUILD_OUTPUTS_DIGEST}"
|
||||
done <<< "${INPUTS_TAGS}"
|
||||
env:
|
||||
STEPS_BUILD_OUTPUTS_DIGEST: ${{ steps.build.outputs.digest }}
|
||||
INPUTS_TAGS: ${{ inputs.tags }}
|
||||
73
.github/actions/builder/machine/action.yml
vendored
73
.github/actions/builder/machine/action.yml
vendored
@@ -1,73 +0,0 @@
|
||||
name: "Machine image builder"
|
||||
description: "Build or copy a machine-specific Docker image"
|
||||
inputs:
|
||||
machine:
|
||||
description: "Machine name"
|
||||
required: true
|
||||
# example: 'raspberrypi4-64'
|
||||
version:
|
||||
description: "Version for the build"
|
||||
required: true
|
||||
# example: '2026.2.0'
|
||||
arch:
|
||||
description: "Architecture for the build"
|
||||
required: true
|
||||
# example: 'aarch64'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Prepare build variables
|
||||
id: vars
|
||||
shell: bash
|
||||
run: |
|
||||
echo "base_image=ghcr.io/home-assistant/${INPUTS_ARCH}-homeassistant:${INPUTS_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build tags array with version-specific tag
|
||||
TAGS=(
|
||||
"ghcr.io/home-assistant/${INPUTS_MACHINE}-homeassistant:${INPUTS_VERSION}"
|
||||
)
|
||||
|
||||
# Add general tag based on version
|
||||
if [[ "${INPUTS_VERSION}" =~ d ]]; then
|
||||
TAGS+=("ghcr.io/home-assistant/${INPUTS_MACHINE}-homeassistant:dev")
|
||||
elif [[ "${INPUTS_VERSION}" =~ b ]]; then
|
||||
TAGS+=("ghcr.io/home-assistant/${INPUTS_MACHINE}-homeassistant:beta")
|
||||
else
|
||||
TAGS+=("ghcr.io/home-assistant/${INPUTS_MACHINE}-homeassistant:stable")
|
||||
fi
|
||||
|
||||
# Output tags using EOF delimiter for multiline
|
||||
{
|
||||
echo 'tags<<EOF'
|
||||
printf '%s\n' "${TAGS[@]}"
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
LABELS=(
|
||||
"io.hass.type=core"
|
||||
"io.hass.machine=${INPUTS_MACHINE}"
|
||||
"org.opencontainers.image.source=https://github.com/home-assistant/core"
|
||||
)
|
||||
|
||||
# Output the labels using EOF delimiter for multiline
|
||||
{
|
||||
echo 'labels<<EOF'
|
||||
printf '%s\n' "${LABELS[@]}"
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
INPUTS_ARCH: ${{ inputs.arch }}
|
||||
INPUTS_VERSION: ${{ inputs.version }}
|
||||
INPUTS_MACHINE: ${{ inputs.machine }}
|
||||
|
||||
- name: Build machine image
|
||||
uses: ./.github/actions/builder/generic
|
||||
with:
|
||||
base-image: ${{ steps.vars.outputs.base_image }}
|
||||
tags: ${{ steps.vars.outputs.tags }}
|
||||
arch: ${{ inputs.arch }}
|
||||
version: ${{ inputs.version }}
|
||||
dockerfile: machine/${{ inputs.machine }}
|
||||
cosign-base-identity: "https://github.com/home-assistant/core/.*"
|
||||
additional-labels: ${{ steps.vars.outputs.labels }}
|
||||
push: false
|
||||
678
.github/workflows/builder.yml
vendored
678
.github/workflows/builder.yml
vendored
@@ -57,10 +57,10 @@ jobs:
|
||||
with:
|
||||
type: ${{ env.BUILD_TYPE }}
|
||||
|
||||
# - name: Verify version
|
||||
# uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# ignore-dev: true
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
ignore-dev: true
|
||||
|
||||
- name: Fail if translations files are checked in
|
||||
run: |
|
||||
@@ -203,30 +203,178 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build variables
|
||||
id: vars
|
||||
shell: bash
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
MATRIX_ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
echo "base_image=ghcr.io/home-assistant/${MATRIX_ARCH}-homeassistant-base:${BASE_IMAGE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
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: ./.github/actions/builder/generic
|
||||
id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
base-image: ${{ steps.vars.outputs.base_image }}
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ steps.vars.outputs.platform }}
|
||||
push: true
|
||||
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 }}
|
||||
arch: ${{ matrix.arch }}
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
dockerfile: ./Dockerfile
|
||||
cosign-base-identity: "https://github.com/home-assistant/docker/.*"
|
||||
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.name }} machine core image
|
||||
name: Build ${{ matrix.machine }} machine core image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ${{ matrix.machine.arch == 'amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
|
||||
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:
|
||||
matrix:
|
||||
machine:
|
||||
- generic-x86-64
|
||||
- intel-nuc
|
||||
- khadas-vim3
|
||||
- odroid-c2
|
||||
- odroid-c4
|
||||
- odroid-m1
|
||||
- odroid-n2
|
||||
- qemuarm-64
|
||||
- qemux86-64
|
||||
- raspberrypi3-64
|
||||
- raspberrypi4-64
|
||||
- raspberrypi5-64
|
||||
- yellow
|
||||
- green
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set build additional args
|
||||
env:
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
# Create general tags
|
||||
if [[ "${VERSION}" =~ d ]]; then
|
||||
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
|
||||
elif [[ "${VERSION}" =~ b ]]; then
|
||||
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2025.11.0 # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
--target /data/machine \
|
||||
--cosign \
|
||||
--machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}"
|
||||
|
||||
publish_ha:
|
||||
name: Publish version files
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_machine"]
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
@@ -234,27 +382,193 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
machine:
|
||||
- { name: generic-x86-64, arch: amd64 }
|
||||
- { name: intel-nuc, arch: amd64 }
|
||||
- { name: qemux86-64, arch: amd64 }
|
||||
- { name: khadas-vim3, arch: aarch64 }
|
||||
- { name: odroid-c2, arch: aarch64 }
|
||||
- { name: odroid-c4, arch: aarch64 }
|
||||
- { name: odroid-m1, arch: aarch64 }
|
||||
- { name: odroid-n2, arch: aarch64 }
|
||||
- { name: qemuarm-64, arch: aarch64 }
|
||||
- { name: raspberrypi3-64, arch: aarch64 }
|
||||
- { name: raspberrypi4-64, arch: aarch64 }
|
||||
- { name: raspberrypi5-64, arch: aarch64 }
|
||||
- { name: yellow, arch: aarch64 }
|
||||
- { name: green, arch: aarch64 }
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify architecture image signatures
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Verifying ${arch} image signature..."
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp https://github.com/home-assistant/core/.* \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
|
||||
done
|
||||
echo "✓ All images verified successfully"
|
||||
|
||||
# Generate all Docker tags based on version string
|
||||
# Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
|
||||
# Examples:
|
||||
# 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
|
||||
# 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
tags: |
|
||||
type=raw,value=${{ needs.init.outputs.version }},priority=9999
|
||||
type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
|
||||
type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
# Use imagetools to copy image blobs directly between registries
|
||||
# This preserves provenance/attestations and seems to be much faster than pull/push
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Copying ${arch} image to DockerHub..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx imagetools create \
|
||||
--tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
|
||||
sleep 10
|
||||
if [ "${attempt}" -eq 3 ]; then
|
||||
echo "Failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
|
||||
done
|
||||
|
||||
- name: Create and push multi-arch manifests
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
REGISTRY: ${{ matrix.registry }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
# Build list of architecture images dynamically
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
ARCH_IMAGES=()
|
||||
for arch in $ARCHS; do
|
||||
ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
|
||||
done
|
||||
|
||||
# Build list of all tags for single manifest creation
|
||||
# Note: Using sep-tags=',' in metadata-action for easier parsing
|
||||
TAG_ARGS=()
|
||||
IFS=',' read -ra TAGS <<< "${META_TAGS}"
|
||||
for tag in "${TAGS[@]}"; do
|
||||
TAG_ARGS+=("--tag" "${tag}")
|
||||
done
|
||||
|
||||
# Create manifest with ALL tags in a single operation (much faster!)
|
||||
echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
|
||||
docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
|
||||
|
||||
# Sign each tag separately (signing requires individual tag names)
|
||||
echo "Signing all tags..."
|
||||
for tag in "${TAGS[@]}"; do
|
||||
echo "Signing ${tag}"
|
||||
cosign sign --yes "${tag}"
|
||||
done
|
||||
|
||||
echo "All manifests created and signed successfully"
|
||||
|
||||
build_python:
|
||||
name: Build PyPi package
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
id-token: write # For PyPI trusted publishing
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
- name: Extract translations
|
||||
run: |
|
||||
tar xvf translations.tar.gz
|
||||
rm translations.tar.gz
|
||||
|
||||
- name: Build package
|
||||
shell: bash
|
||||
run: |
|
||||
# Remove dist, build, and homeassistant.egg-info
|
||||
# when build locally for testing!
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- name: Upload package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
hassfest-image:
|
||||
name: Build and test hassfest image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
attestations: write # For build provenance attestation
|
||||
id-token: write # For build provenance attestation
|
||||
needs: ["init"]
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
env:
|
||||
HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
@@ -262,289 +576,31 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build machine image
|
||||
uses: ./.github/actions/builder/machine
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
machine: ${{ matrix.machine.name }}
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
arch: ${{ matrix.machine.arch }}
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
load: true
|
||||
tags: ${{ env.HASSFEST_IMAGE_TAG }}
|
||||
|
||||
# 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: Run hassfest against core
|
||||
run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
|
||||
|
||||
# - 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: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
|
||||
|
||||
# - name: Update version file
|
||||
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# key: "homeassistant[]"
|
||||
# key-description: "Home Assistant Core"
|
||||
# version: ${{ needs.init.outputs.version }}
|
||||
# channel: ${{ needs.init.outputs.channel }}
|
||||
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
|
||||
# - name: Update version file (stable -> beta)
|
||||
# if: needs.init.outputs.channel == 'stable'
|
||||
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# key: "homeassistant[]"
|
||||
# key-description: "Home Assistant Core"
|
||||
# version: ${{ needs.init.outputs.version }}
|
||||
# channel: beta
|
||||
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
|
||||
# publish_container:
|
||||
# name: Publish meta container for ${{ matrix.registry }}
|
||||
# environment: ${{ needs.init.outputs.channel }}
|
||||
# if: github.repository_owner == 'home-assistant'
|
||||
# needs: ["init", "build_base"]
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# packages: write # To push to GHCR
|
||||
# id-token: write # For cosign signing
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
# steps:
|
||||
# - name: Install Cosign
|
||||
# uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
# with:
|
||||
# cosign-release: "v2.5.3"
|
||||
# - name: Install Cosign
|
||||
# uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
# with:
|
||||
# cosign-release: "v2.5.3"
|
||||
|
||||
# - name: Login to DockerHub
|
||||
# if: matrix.registry == 'docker.io/homeassistant'
|
||||
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
# with:
|
||||
# username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
# password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# - name: Login to GitHub Container Registry
|
||||
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.repository_owner }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# - name: Verify architecture image signatures
|
||||
# shell: bash
|
||||
# env:
|
||||
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# run: |
|
||||
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
# for arch in $ARCHS; do
|
||||
# echo "Verifying ${arch} image signature..."
|
||||
# cosign verify \
|
||||
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
# --certificate-identity-regexp https://github.com/home-assistant/core/.* \
|
||||
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
|
||||
# done
|
||||
# echo "✓ All images verified successfully"
|
||||
|
||||
# # Generate all Docker tags based on version string
|
||||
# # Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
|
||||
# # Examples:
|
||||
# # 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
|
||||
# # 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
|
||||
# # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
# - name: Generate Docker metadata
|
||||
# id: meta
|
||||
# uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
# with:
|
||||
# images: ${{ matrix.registry }}/home-assistant
|
||||
# sep-tags: ","
|
||||
# tags: |
|
||||
# type=raw,value=${{ needs.init.outputs.version }},priority=9999
|
||||
# type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
|
||||
# type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
# type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
# type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
# type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
# type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
|
||||
|
||||
# - name: Copy architecture images to DockerHub
|
||||
# if: matrix.registry == 'docker.io/homeassistant'
|
||||
# shell: bash
|
||||
# env:
|
||||
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# run: |
|
||||
# # Use imagetools to copy image blobs directly between registries
|
||||
# # This preserves provenance/attestations and seems to be much faster than pull/push
|
||||
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
# for arch in $ARCHS; do
|
||||
# echo "Copying ${arch} image to DockerHub..."
|
||||
# for attempt in 1 2 3; do
|
||||
# if docker buildx imagetools create \
|
||||
# --tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
|
||||
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
|
||||
# break
|
||||
# fi
|
||||
# echo "Attempt ${attempt} failed, retrying in 10 seconds..."
|
||||
# sleep 10
|
||||
# if [ "${attempt}" -eq 3 ]; then
|
||||
# echo "Failed after 3 attempts"
|
||||
# exit 1
|
||||
# fi
|
||||
# done
|
||||
# cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
|
||||
# done
|
||||
|
||||
# - name: Create and push multi-arch manifests
|
||||
# shell: bash
|
||||
# env:
|
||||
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
# REGISTRY: ${{ matrix.registry }}
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
# run: |
|
||||
# # Build list of architecture images dynamically
|
||||
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
# ARCH_IMAGES=()
|
||||
# for arch in $ARCHS; do
|
||||
# ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
|
||||
# done
|
||||
|
||||
# # Build list of all tags for single manifest creation
|
||||
# # Note: Using sep-tags=',' in metadata-action for easier parsing
|
||||
# TAG_ARGS=()
|
||||
# IFS=',' read -ra TAGS <<< "${META_TAGS}"
|
||||
# for tag in "${TAGS[@]}"; do
|
||||
# TAG_ARGS+=("--tag" "${tag}")
|
||||
# done
|
||||
|
||||
# # Create manifest with ALL tags in a single operation (much faster!)
|
||||
# echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
|
||||
# docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
|
||||
|
||||
# # Sign each tag separately (signing requires individual tag names)
|
||||
# echo "Signing all tags..."
|
||||
# for tag in "${TAGS[@]}"; do
|
||||
# echo "Signing ${tag}"
|
||||
# cosign sign --yes "${tag}"
|
||||
# done
|
||||
|
||||
# echo "All manifests created and signed successfully"
|
||||
|
||||
# build_python:
|
||||
# name: Build PyPi package
|
||||
# environment: ${{ needs.init.outputs.channel }}
|
||||
# needs: ["init", "build_base"]
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# id-token: write # For PyPI trusted publishing
|
||||
# if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
# steps:
|
||||
# - name: Checkout the repository
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# persist-credentials: false
|
||||
|
||||
# - name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
# uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
# with:
|
||||
# python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
# - name: Download translations
|
||||
# uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
# with:
|
||||
# name: translations
|
||||
|
||||
# - name: Extract translations
|
||||
# run: |
|
||||
# tar xvf translations.tar.gz
|
||||
# rm translations.tar.gz
|
||||
|
||||
# - name: Build package
|
||||
# shell: bash
|
||||
# run: |
|
||||
# # Remove dist, build, and homeassistant.egg-info
|
||||
# # when build locally for testing!
|
||||
# pip install build
|
||||
# python -m build
|
||||
|
||||
# - name: Upload package to PyPI
|
||||
# uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
# with:
|
||||
# skip-existing: true
|
||||
|
||||
# hassfest-image:
|
||||
# name: Build and test hassfest image
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# packages: write # To push to GHCR
|
||||
# attestations: write # For build provenance attestation
|
||||
# id-token: write # For build provenance attestation
|
||||
# needs: ["init"]
|
||||
# if: github.repository_owner == 'home-assistant'
|
||||
# env:
|
||||
# HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
|
||||
# HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# persist-credentials: false
|
||||
|
||||
# - name: Login to GitHub Container Registry
|
||||
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.repository_owner }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# - name: Build Docker image
|
||||
# uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
# with:
|
||||
# context: . # So action will not pull the repository again
|
||||
# file: ./script/hassfest/docker/Dockerfile
|
||||
# load: true
|
||||
# tags: ${{ env.HASSFEST_IMAGE_TAG }}
|
||||
|
||||
# - name: Run hassfest against core
|
||||
# run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
|
||||
|
||||
# - name: Push Docker image
|
||||
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
# id: push
|
||||
# uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
# with:
|
||||
# context: . # So action will not pull the repository again
|
||||
# file: ./script/hassfest/docker/Dockerfile
|
||||
# push: true
|
||||
# tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
|
||||
|
||||
# - name: Generate artifact attestation
|
||||
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
# uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
|
||||
# with:
|
||||
# subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
# subject-digest: ${{ steps.push.outputs.digest }}
|
||||
# push-to-registry: true
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
1
CODEOWNERS
generated
1
CODEOWNERS
generated
@@ -1966,7 +1966,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/zone/ @home-assistant/core
|
||||
/tests/components/zone/ @home-assistant/core
|
||||
/homeassistant/components/zoneminder/ @rohankapoorcom @nabbi
|
||||
/tests/components/zoneminder/ @rohankapoorcom @nabbi
|
||||
/homeassistant/components/zwave_js/ @home-assistant/z-wave
|
||||
/tests/components/zwave_js/ @home-assistant/z-wave
|
||||
/homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS
|
||||
|
||||
@@ -4,16 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from airos.airos6 import AirOS6
|
||||
from airos.airos8 import AirOS8
|
||||
from airos.exceptions import (
|
||||
AirOSConnectionAuthenticationError,
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDataMissingError,
|
||||
AirOSDeviceConnectionError,
|
||||
AirOSKeyDataMissingError,
|
||||
)
|
||||
from airos.helpers import DetectDeviceData, async_get_firmware_data
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
@@ -24,11 +15,6 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
@@ -53,40 +39,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
|
||||
hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL]
|
||||
)
|
||||
|
||||
conn_data = {
|
||||
CONF_HOST: entry.data[CONF_HOST],
|
||||
CONF_USERNAME: entry.data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry.data[CONF_PASSWORD],
|
||||
"use_ssl": entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
"session": session,
|
||||
}
|
||||
|
||||
# Determine firmware version before creating the device instance
|
||||
try:
|
||||
device_data: DetectDeviceData = await async_get_firmware_data(**conn_data)
|
||||
except (
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDeviceConnectionError,
|
||||
TimeoutError,
|
||||
) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
except (
|
||||
AirOSConnectionAuthenticationError,
|
||||
AirOSDataMissingError,
|
||||
) as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except AirOSKeyDataMissingError as err:
|
||||
raise ConfigEntryError("key_data_missing") from err
|
||||
except Exception as err:
|
||||
raise ConfigEntryError("unknown") from err
|
||||
|
||||
airos_class: type[AirOS8 | AirOS6] = (
|
||||
AirOS8 if device_data["fw_major"] == 8 else AirOS6
|
||||
airos_device = AirOS8(
|
||||
host=entry.data[CONF_HOST],
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=session,
|
||||
use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
)
|
||||
|
||||
airos_device = airos_class(**conn_data)
|
||||
|
||||
coordinator = AirOSDataUpdateCoordinator(hass, entry, device_data, airos_device)
|
||||
coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
@@ -4,9 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from airos.data import AirOSDataBaseClass
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -20,24 +18,25 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .coordinator import AirOS8Data, AirOSConfigEntry, AirOSDataUpdateCoordinator
|
||||
from .entity import AirOSEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirOSBinarySensorEntityDescription(
|
||||
BinarySensorEntityDescription,
|
||||
Generic[AirOSDataModel],
|
||||
):
|
||||
class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describe an AirOS binary sensor."""
|
||||
|
||||
value_fn: Callable[[AirOSDataModel], bool]
|
||||
value_fn: Callable[[AirOS8Data], bool]
|
||||
|
||||
|
||||
AirOS8BinarySensorEntityDescription = AirOSBinarySensorEntityDescription[AirOS8Data]
|
||||
|
||||
COMMON_BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
|
||||
BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
|
||||
AirOSBinarySensorEntityDescription(
|
||||
key="portfw",
|
||||
translation_key="port_forwarding",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.portfw,
|
||||
),
|
||||
AirOSBinarySensorEntityDescription(
|
||||
key="dhcp_client",
|
||||
translation_key="dhcp_client",
|
||||
@@ -54,23 +53,6 @@ COMMON_BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
AirOSBinarySensorEntityDescription(
|
||||
key="pppoe",
|
||||
translation_key="pppoe",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.services.pppoe,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
AIROS8_BINARY_SENSORS: tuple[AirOS8BinarySensorEntityDescription, ...] = (
|
||||
AirOS8BinarySensorEntityDescription(
|
||||
key="portfw",
|
||||
translation_key="port_forwarding",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.portfw,
|
||||
),
|
||||
AirOS8BinarySensorEntityDescription(
|
||||
key="dhcp6_server",
|
||||
translation_key="dhcp6_server",
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
@@ -78,6 +60,14 @@ AIROS8_BINARY_SENSORS: tuple[AirOS8BinarySensorEntityDescription, ...] = (
|
||||
value_fn=lambda data: data.services.dhcp6d_stateful,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
AirOSBinarySensorEntityDescription(
|
||||
key="pppoe",
|
||||
translation_key="pppoe",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.services.pppoe,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -89,20 +79,10 @@ async def async_setup_entry(
|
||||
"""Set up the AirOS binary sensors from a config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[BinarySensorEntity] = []
|
||||
entities.extend(
|
||||
AirOSBinarySensor(coordinator, description)
|
||||
for description in COMMON_BINARY_SENSORS
|
||||
async_add_entities(
|
||||
AirOSBinarySensor(coordinator, description) for description in BINARY_SENSORS
|
||||
)
|
||||
|
||||
if coordinator.device_data["fw_major"] == 8:
|
||||
entities.extend(
|
||||
AirOSBinarySensor(coordinator, description)
|
||||
for description in AIROS8_BINARY_SENSORS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AirOSBinarySensor(AirOSEntity, BinarySensorEntity):
|
||||
"""Representation of a binary sensor."""
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from airos.exceptions import AirOSException
|
||||
|
||||
from homeassistant.components.button import (
|
||||
@@ -16,6 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .coordinator import DOMAIN, AirOSConfigEntry, AirOSDataUpdateCoordinator
|
||||
from .entity import AirOSEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
REBOOT_BUTTON = ButtonEntityDescription(
|
||||
|
||||
@@ -7,8 +7,6 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from airos.airos6 import AirOS6
|
||||
from airos.airos8 import AirOS8
|
||||
from airos.discovery import airos_discover_devices
|
||||
from airos.exceptions import (
|
||||
AirOSConnectionAuthenticationError,
|
||||
@@ -19,7 +17,6 @@ from airos.exceptions import (
|
||||
AirOSKeyDataMissingError,
|
||||
AirOSListenerError,
|
||||
)
|
||||
from airos.helpers import DetectDeviceData, async_get_firmware_data
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
@@ -56,11 +53,10 @@ from .const import (
|
||||
MAC_ADDRESS,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
)
|
||||
from .coordinator import AirOS8
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AirOSDeviceDetect = AirOS8 | AirOS6
|
||||
|
||||
# Discovery duration in seconds, airOS announces every 20 seconds
|
||||
DISCOVER_INTERVAL: int = 30
|
||||
|
||||
@@ -96,7 +92,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
super().__init__()
|
||||
self.airos_device: AirOSDeviceDetect
|
||||
self.airos_device: AirOS8
|
||||
self.errors: dict[str, str] = {}
|
||||
self.discovered_devices: dict[str, dict[str, Any]] = {}
|
||||
self.discovery_abort_reason: str | None = None
|
||||
@@ -139,14 +135,16 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
|
||||
)
|
||||
|
||||
airos_device = AirOS8(
|
||||
host=config_data[CONF_HOST],
|
||||
username=config_data[CONF_USERNAME],
|
||||
password=config_data[CONF_PASSWORD],
|
||||
session=session,
|
||||
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
)
|
||||
try:
|
||||
device_data: DetectDeviceData = await async_get_firmware_data(
|
||||
host=config_data[CONF_HOST],
|
||||
username=config_data[CONF_USERNAME],
|
||||
password=config_data[CONF_PASSWORD],
|
||||
session=session,
|
||||
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
)
|
||||
await airos_device.login()
|
||||
airos_data = await airos_device.status()
|
||||
|
||||
except (
|
||||
AirOSConnectionSetupError,
|
||||
@@ -161,14 +159,14 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception during credential validation")
|
||||
self.errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(device_data["mac"])
|
||||
await self.async_set_unique_id(airos_data.derived.mac)
|
||||
|
||||
if self.source in [SOURCE_REAUTH, SOURCE_RECONFIGURE]:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
else:
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return {"title": device_data["hostname"], "data": config_data}
|
||||
return {"title": airos_data.host.hostname, "data": config_data}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from airos.airos6 import AirOS6, AirOS6Data
|
||||
from airos.airos8 import AirOS8, AirOS8Data
|
||||
from airos.exceptions import (
|
||||
AirOSConnectionAuthenticationError,
|
||||
@@ -12,7 +11,6 @@ from airos.exceptions import (
|
||||
AirOSDataMissingError,
|
||||
AirOSDeviceConnectionError,
|
||||
)
|
||||
from airos.helpers import DetectDeviceData
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -23,28 +21,19 @@ from .const import DOMAIN, SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AirOSDeviceDetect = AirOS8 | AirOS6
|
||||
AirOSDataDetect = AirOS8Data | AirOS6Data
|
||||
|
||||
type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator]
|
||||
|
||||
|
||||
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
|
||||
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
|
||||
"""Class to manage fetching AirOS data from single endpoint."""
|
||||
|
||||
airos_device: AirOSDeviceDetect
|
||||
config_entry: AirOSConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: AirOSConfigEntry,
|
||||
device_data: DetectDeviceData,
|
||||
airos_device: AirOSDeviceDetect,
|
||||
self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS8
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.airos_device = airos_device
|
||||
self.device_data = device_data
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
@@ -53,7 +42,7 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> AirOSDataDetect:
|
||||
async def _async_update_data(self) -> AirOS8Data:
|
||||
"""Fetch data from AirOS."""
|
||||
try:
|
||||
await self.airos_device.login()
|
||||
@@ -73,7 +62,7 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
except AirOSDataMissingError as err:
|
||||
except (AirOSDataMissingError,) as err:
|
||||
_LOGGER.error("Expected data not returned by airOS device: %s", err)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airos",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["airos==0.6.4"]
|
||||
}
|
||||
|
||||
@@ -42,20 +42,16 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: No way to detect device on the network
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: single airOS device per config entry; peer/remote endpoints are not modeled as child devices/entities at this time
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
@@ -65,10 +61,8 @@ rules:
|
||||
status: exempt
|
||||
comment: no (custom) icons used or envisioned
|
||||
reconfiguration-flow: done
|
||||
repair-issues: done
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: single airOS device per config entry; peer/remote endpoints are not modeled as child devices/entities at this time
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
@@ -5,14 +5,8 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from airos.data import (
|
||||
AirOSDataBaseClass,
|
||||
DerivedWirelessMode,
|
||||
DerivedWirelessRole,
|
||||
NetRole,
|
||||
)
|
||||
from airos.data import DerivedWirelessMode, DerivedWirelessRole, NetRole
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -43,19 +37,15 @@ WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirOSSensorEntityDescription(SensorEntityDescription, Generic[AirOSDataModel]):
|
||||
class AirOSSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describe an AirOS sensor."""
|
||||
|
||||
value_fn: Callable[[AirOSDataModel], StateType]
|
||||
value_fn: Callable[[AirOS8Data], StateType]
|
||||
|
||||
|
||||
AirOS8SensorEntityDescription = AirOSSensorEntityDescription[AirOS8Data]
|
||||
|
||||
COMMON_SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
|
||||
SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
|
||||
AirOSSensorEntityDescription(
|
||||
key="host_cpuload",
|
||||
translation_key="host_cpuload",
|
||||
@@ -85,6 +75,54 @@ COMMON_SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
|
||||
translation_key="wireless_essid",
|
||||
value_fn=lambda data: data.wireless.essid,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_antenna_gain",
|
||||
translation_key="wireless_antenna_gain",
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.wireless.antenna_gain,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_throughput_tx",
|
||||
translation_key="wireless_throughput_tx",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.throughput.tx,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_throughput_rx",
|
||||
translation_key="wireless_throughput_rx",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.throughput.rx,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_polling_dl_capacity",
|
||||
translation_key="wireless_polling_dl_capacity",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.polling.dl_capacity,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_polling_ul_capacity",
|
||||
translation_key="wireless_polling_ul_capacity",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.polling.ul_capacity,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="host_uptime",
|
||||
translation_key="host_uptime",
|
||||
@@ -120,57 +158,6 @@ COMMON_SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
|
||||
options=WIRELESS_ROLE_OPTIONS,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_antenna_gain",
|
||||
translation_key="wireless_antenna_gain",
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.wireless.antenna_gain,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_polling_dl_capacity",
|
||||
translation_key="wireless_polling_dl_capacity",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.polling.dl_capacity,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_polling_ul_capacity",
|
||||
translation_key="wireless_polling_ul_capacity",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.polling.ul_capacity,
|
||||
),
|
||||
)
|
||||
|
||||
AIROS8_SENSORS: tuple[AirOS8SensorEntityDescription, ...] = (
|
||||
AirOS8SensorEntityDescription(
|
||||
key="wireless_throughput_tx",
|
||||
translation_key="wireless_throughput_tx",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.throughput.tx,
|
||||
),
|
||||
AirOS8SensorEntityDescription(
|
||||
key="wireless_throughput_rx",
|
||||
translation_key="wireless_throughput_rx",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.throughput.rx,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -182,14 +169,7 @@ async def async_setup_entry(
|
||||
"""Set up the AirOS sensors from a config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AirOSSensor(coordinator, description) for description in COMMON_SENSORS
|
||||
)
|
||||
|
||||
if coordinator.device_data["fw_major"] == 8:
|
||||
async_add_entities(
|
||||
AirOSSensor(coordinator, description) for description in AIROS8_SENSORS
|
||||
)
|
||||
async_add_entities(AirOSSensor(coordinator, description) for description in SENSORS)
|
||||
|
||||
|
||||
class AirOSSensor(AirOSEntity, SensorEntity):
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-bsblan==5.0.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration has a fixed single device.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration provides a limited number of entities, all of which are useful to users.
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration doesn't have any cases where raising an issue is needed.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration has a fixed single device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
@@ -14,7 +14,6 @@ PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.WATER_HEATER,
|
||||
]
|
||||
|
||||
|
||||
@@ -158,119 +158,6 @@
|
||||
"winter": "mdi:snowflake"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"alarm_code": {
|
||||
"default": "mdi:alert-circle",
|
||||
"state": {
|
||||
"no_alarm": "mdi:check-circle"
|
||||
}
|
||||
},
|
||||
"battery_level": {
|
||||
"default": "mdi:battery"
|
||||
},
|
||||
"boiler_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"calculated_heating_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"calculated_target_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"charging_power": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
"circuit_target_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"co2_percent": {
|
||||
"default": "mdi:molecule-co2"
|
||||
},
|
||||
"collector_power": {
|
||||
"default": "mdi:solar-power"
|
||||
},
|
||||
"collector_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"dhw_measured_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"energy_consumption": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"energy_smart_grid_yesterday": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"energy_today": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"energy_total": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"energy_yesterday": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"fuel_level": {
|
||||
"default": "mdi:gauge"
|
||||
},
|
||||
"humidity": {
|
||||
"default": "mdi:water-percent"
|
||||
},
|
||||
"mixer_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"outdoor_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"pk1_function": {
|
||||
"default": "mdi:cog",
|
||||
"state": {
|
||||
"cooling": "mdi:snowflake-thermometer",
|
||||
"off": "mdi:cog-off",
|
||||
"summer": "mdi:weather-sunny",
|
||||
"winter": "mdi:snowflake"
|
||||
}
|
||||
},
|
||||
"pm10_level": {
|
||||
"default": "mdi:air-filter",
|
||||
"state": {
|
||||
"exceeded": "mdi:alert",
|
||||
"no_sensor": "mdi:cancel",
|
||||
"normal": "mdi:air-filter",
|
||||
"warning": "mdi:alert-circle-outline"
|
||||
}
|
||||
},
|
||||
"pm25_level": {
|
||||
"default": "mdi:air-filter",
|
||||
"state": {
|
||||
"exceeded": "mdi:alert",
|
||||
"no_sensor": "mdi:cancel",
|
||||
"normal": "mdi:air-filter",
|
||||
"warning": "mdi:alert-circle-outline"
|
||||
}
|
||||
},
|
||||
"return_circuit_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"tank_temperature_t2": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"tank_temperature_t3": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"tank_temperature_t4": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"target_heating_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"default": "mdi:alert",
|
||||
"state": {
|
||||
"no_alarm": "mdi:check-circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -203,219 +203,6 @@
|
||||
"winter": "Winter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"actual_buffer_temp": {
|
||||
"name": "Actual buffer temperature"
|
||||
},
|
||||
"actual_dhw_temp": {
|
||||
"name": "Actual DHW temperature"
|
||||
},
|
||||
"actual_hc_temperature_zone": {
|
||||
"name": "Actual heating circuit {zone} temperature"
|
||||
},
|
||||
"actual_upper_source_temp": {
|
||||
"name": "Actual upper source temperature"
|
||||
},
|
||||
"alarm_code": {
|
||||
"name": "Alarm code",
|
||||
"state": {
|
||||
"battery_fault": "Battery fault",
|
||||
"damaged_outdoor_temp": "Damaged outdoor temperature sensor",
|
||||
"damaged_return_temp": "Damaged return temperature sensor",
|
||||
"discharged_battery": "Discharged battery",
|
||||
"internal_af": "Internal fault",
|
||||
"low_battery_level": "Low battery level",
|
||||
"no_alarm": "No alarm",
|
||||
"no_battery": "No battery",
|
||||
"no_power": "No power",
|
||||
"no_pump": "No pump",
|
||||
"pump_fault": "Pump fault"
|
||||
}
|
||||
},
|
||||
"battery_level": {
|
||||
"name": "Battery level"
|
||||
},
|
||||
"boiler_temperature": {
|
||||
"name": "Boiler temperature"
|
||||
},
|
||||
"buffer_return_temperature": {
|
||||
"name": "Buffer return temperature"
|
||||
},
|
||||
"buffer_set_temperature": {
|
||||
"name": "Buffer set temperature"
|
||||
},
|
||||
"calculated_buffer_temp": {
|
||||
"name": "Calculated buffer temperature"
|
||||
},
|
||||
"calculated_dhw_temp": {
|
||||
"name": "Calculated DHW temperature"
|
||||
},
|
||||
"calculated_heating_temperature": {
|
||||
"name": "Calculated heating temperature"
|
||||
},
|
||||
"calculated_target_temperature": {
|
||||
"name": "Calculated target temperature"
|
||||
},
|
||||
"calculated_upper_source_temp": {
|
||||
"name": "Calculated upper source temperature"
|
||||
},
|
||||
"charging_power": {
|
||||
"name": "Charging power"
|
||||
},
|
||||
"circuit_target_temperature": {
|
||||
"name": "Circuit target temperature"
|
||||
},
|
||||
"co2_percent": {
|
||||
"name": "CO2 percent"
|
||||
},
|
||||
"collector_power": {
|
||||
"name": "Collector power"
|
||||
},
|
||||
"collector_temperature": {
|
||||
"name": "Collector temperature"
|
||||
},
|
||||
"dhw_measured_temperature": {
|
||||
"name": "DHW measured temperature"
|
||||
},
|
||||
"dhw_temperature": {
|
||||
"name": "DHW temperature"
|
||||
},
|
||||
"energy_consumption": {
|
||||
"name": "Energy consumption"
|
||||
},
|
||||
"energy_smart_grid_yesterday": {
|
||||
"name": "Energy smart grid yesterday"
|
||||
},
|
||||
"energy_today": {
|
||||
"name": "Energy today"
|
||||
},
|
||||
"energy_total": {
|
||||
"name": "Energy total"
|
||||
},
|
||||
"energy_yesterday": {
|
||||
"name": "Energy yesterday"
|
||||
},
|
||||
"fuel_level": {
|
||||
"name": "Fuel level"
|
||||
},
|
||||
"heating_target_temperature_zone": {
|
||||
"name": "Heating circuit {zone} target temperature"
|
||||
},
|
||||
"lower_source_temperature": {
|
||||
"name": "Lower source temperature"
|
||||
},
|
||||
"mixer_temperature": {
|
||||
"name": "Mixer temperature"
|
||||
},
|
||||
"mixer_temperature_zone": {
|
||||
"name": "Mixer {zone} temperature"
|
||||
},
|
||||
"outdoor_temperature": {
|
||||
"name": "Outdoor temperature"
|
||||
},
|
||||
"pk1_function": {
|
||||
"name": "PK1 function",
|
||||
"state": {
|
||||
"cooling": "Cooling",
|
||||
"holiday": "Holiday",
|
||||
"nano_nr_1": "Nano 1",
|
||||
"nano_nr_2": "Nano 2",
|
||||
"nano_nr_3": "Nano 3",
|
||||
"nano_nr_4": "Nano 4",
|
||||
"nano_nr_5": "Nano 5",
|
||||
"off": "Off",
|
||||
"on": "On",
|
||||
"summer": "Summer",
|
||||
"winter": "Winter"
|
||||
}
|
||||
},
|
||||
"pm10_level": {
|
||||
"name": "PM10 level",
|
||||
"state": {
|
||||
"exceeded": "Exceeded",
|
||||
"no_sensor": "No sensor",
|
||||
"normal": "Normal",
|
||||
"warning": "Warning"
|
||||
}
|
||||
},
|
||||
"pm1_level": {
|
||||
"name": "PM1 level"
|
||||
},
|
||||
"pm25_level": {
|
||||
"name": "PM2.5 level",
|
||||
"state": {
|
||||
"exceeded": "Exceeded",
|
||||
"no_sensor": "No sensor",
|
||||
"normal": "Normal",
|
||||
"warning": "Warning"
|
||||
}
|
||||
},
|
||||
"pm4_level": {
|
||||
"name": "PM4 level"
|
||||
},
|
||||
"preset_mode": {
|
||||
"name": "Preset mode"
|
||||
},
|
||||
"protection_temperature": {
|
||||
"name": "Protection temperature"
|
||||
},
|
||||
"pump_status": {
|
||||
"name": "Pump status",
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"on": "On"
|
||||
}
|
||||
},
|
||||
"return_circuit_temperature": {
|
||||
"name": "Return circuit temperature"
|
||||
},
|
||||
"set_target_temperature": {
|
||||
"name": "Set target temperature"
|
||||
},
|
||||
"tank_temperature_t2": {
|
||||
"name": "Tank T2 bottom temperature"
|
||||
},
|
||||
"tank_temperature_t3": {
|
||||
"name": "Tank T3 top temperature"
|
||||
},
|
||||
"tank_temperature_t4": {
|
||||
"name": "Tank T4 temperature"
|
||||
},
|
||||
"target_heating_temperature": {
|
||||
"name": "Target heating temperature"
|
||||
},
|
||||
"target_temperature": {
|
||||
"name": "Target temperature"
|
||||
},
|
||||
"temperature_alert": {
|
||||
"name": "Temperature alert",
|
||||
"state": {
|
||||
"alert": "Alert",
|
||||
"no_alert": "No alert"
|
||||
}
|
||||
},
|
||||
"upper_source_temperature": {
|
||||
"name": "Upper source temperature"
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Ventilation alarm",
|
||||
"state": {
|
||||
"ahu_alarm": "AHU alarm",
|
||||
"bot_alarm": "BOT alarm",
|
||||
"damaged_exhaust_sensor": "Damaged exhaust sensor",
|
||||
"damaged_preheater_sensor": "Damaged preheater sensor",
|
||||
"damaged_supply_and_exhaust_sensors": "Damaged supply and exhaust sensors",
|
||||
"damaged_supply_sensor": "Damaged supply sensor",
|
||||
"no_alarm": "No alarm"
|
||||
}
|
||||
},
|
||||
"ventilation_gear": {
|
||||
"name": "Ventilation gear"
|
||||
},
|
||||
"weather_curve": {
|
||||
"name": "Weather curve"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,10 @@
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from pyecobee import (
|
||||
ECOBEE_API_KEY,
|
||||
ECOBEE_PASSWORD,
|
||||
ECOBEE_REFRESH_TOKEN,
|
||||
ECOBEE_USERNAME,
|
||||
Ecobee,
|
||||
ExpiredTokenError,
|
||||
)
|
||||
from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, Ecobee, ExpiredTokenError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
@@ -25,19 +18,10 @@ type EcobeeConfigEntry = ConfigEntry[EcobeeData]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool:
|
||||
"""Set up ecobee via a config entry."""
|
||||
api_key = entry.data.get(CONF_API_KEY)
|
||||
username = entry.data.get(CONF_USERNAME)
|
||||
password = entry.data.get(CONF_PASSWORD)
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
refresh_token = entry.data[CONF_REFRESH_TOKEN]
|
||||
|
||||
runtime_data = EcobeeData(
|
||||
hass,
|
||||
entry,
|
||||
api_key=api_key,
|
||||
username=username,
|
||||
password=password,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
runtime_data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token)
|
||||
|
||||
if not await runtime_data.refresh():
|
||||
return False
|
||||
@@ -62,32 +46,14 @@ class EcobeeData:
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
api_key: str | None = None,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
refresh_token: str | None = None,
|
||||
self, hass: HomeAssistant, entry: ConfigEntry, api_key: str, refresh_token: str
|
||||
) -> None:
|
||||
"""Initialize the Ecobee data object."""
|
||||
self._hass = hass
|
||||
self.entry = entry
|
||||
|
||||
if api_key:
|
||||
self.ecobee = Ecobee(
|
||||
config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token}
|
||||
)
|
||||
elif username and password:
|
||||
self.ecobee = Ecobee(
|
||||
config={
|
||||
ECOBEE_USERNAME: username,
|
||||
ECOBEE_PASSWORD: password,
|
||||
ECOBEE_REFRESH_TOKEN: refresh_token,
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise ValueError("No ecobee credentials provided")
|
||||
self.ecobee = Ecobee(
|
||||
config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token}
|
||||
)
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
async def update(self):
|
||||
@@ -103,23 +69,12 @@ class EcobeeData:
|
||||
"""Refresh ecobee tokens and update config entry."""
|
||||
_LOGGER.debug("Refreshing ecobee tokens and updating config entry")
|
||||
if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens):
|
||||
data = {}
|
||||
if self.ecobee.config.get(ECOBEE_API_KEY):
|
||||
data = {
|
||||
CONF_API_KEY: self.ecobee.config[ECOBEE_API_KEY],
|
||||
CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN],
|
||||
}
|
||||
elif self.ecobee.config.get(ECOBEE_USERNAME) and self.ecobee.config.get(
|
||||
ECOBEE_PASSWORD
|
||||
):
|
||||
data = {
|
||||
CONF_USERNAME: self.ecobee.config[ECOBEE_USERNAME],
|
||||
CONF_PASSWORD: self.ecobee.config[ECOBEE_PASSWORD],
|
||||
CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN],
|
||||
}
|
||||
self._hass.config_entries.async_update_entry(
|
||||
self.entry,
|
||||
data=data,
|
||||
data={
|
||||
CONF_API_KEY: self.ecobee.config[ECOBEE_API_KEY],
|
||||
CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN],
|
||||
},
|
||||
)
|
||||
return True
|
||||
_LOGGER.error("Error refreshing ecobee tokens")
|
||||
|
||||
@@ -2,21 +2,15 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyecobee import ECOBEE_API_KEY, ECOBEE_PASSWORD, ECOBEE_USERNAME, Ecobee
|
||||
from pyecobee import ECOBEE_API_KEY, Ecobee
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
|
||||
from .const import CONF_REFRESH_TOKEN, DOMAIN
|
||||
|
||||
_USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_API_KEY): str,
|
||||
vol.Optional(CONF_USERNAME): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
|
||||
|
||||
|
||||
class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@@ -33,34 +27,13 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
# Use the user-supplied API key to attempt to obtain a PIN from ecobee.
|
||||
self._ecobee = Ecobee(config={ECOBEE_API_KEY: user_input[CONF_API_KEY]})
|
||||
|
||||
if api_key and not (username or password):
|
||||
# Use the user-supplied API key to attempt to obtain a PIN from ecobee.
|
||||
self._ecobee = Ecobee(config={ECOBEE_API_KEY: api_key})
|
||||
if await self.hass.async_add_executor_job(self._ecobee.request_pin):
|
||||
# We have a PIN; move to the next step of the flow.
|
||||
return await self.async_step_authorize()
|
||||
errors["base"] = "pin_request_failed"
|
||||
elif username and password and not api_key:
|
||||
self._ecobee = Ecobee(
|
||||
config={
|
||||
ECOBEE_USERNAME: username,
|
||||
ECOBEE_PASSWORD: password,
|
||||
}
|
||||
)
|
||||
if await self.hass.async_add_executor_job(self._ecobee.refresh_tokens):
|
||||
config = {
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
|
||||
}
|
||||
return self.async_create_entry(title=DOMAIN, data=config)
|
||||
errors["base"] = "login_failed"
|
||||
else:
|
||||
errors["base"] = "invalid_auth"
|
||||
if await self.hass.async_add_executor_job(self._ecobee.request_pin):
|
||||
# We have a PIN; move to the next step of the flow.
|
||||
return await self.async_step_authorize()
|
||||
errors["base"] = "pin_request_failed"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
},
|
||||
"error": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"login_failed": "Error authenticating with ecobee; please verify your credentials are correct.",
|
||||
"pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.",
|
||||
"token_request_failed": "Error requesting tokens from ecobee; please try again."
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -71,11 +70,6 @@ class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
authtoken = await self.auth.async_register()
|
||||
if authtoken:
|
||||
_LOGGER.debug("Write config entry for HomematicIP Cloud")
|
||||
if self.source == "reauth":
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={HMIPC_AUTHTOKEN: authtoken},
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=self.auth.config[HMIPC_HAPID],
|
||||
data={
|
||||
@@ -84,50 +78,11 @@ class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
HMIPC_NAME: self.auth.config.get(HMIPC_NAME),
|
||||
},
|
||||
)
|
||||
if self.source == "reauth":
|
||||
errors["base"] = "connection_aborted"
|
||||
else:
|
||||
return self.async_abort(reason="connection_aborted")
|
||||
else:
|
||||
errors["base"] = "press_the_button"
|
||||
return self.async_abort(reason="connection_aborted")
|
||||
errors["base"] = "press_the_button"
|
||||
|
||||
return self.async_show_form(step_id="link", errors=errors)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication when the auth token becomes invalid."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth confirmation and start link process."""
|
||||
errors = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
config = {
|
||||
HMIPC_HAPID: reauth_entry.data[HMIPC_HAPID],
|
||||
HMIPC_PIN: user_input.get(HMIPC_PIN),
|
||||
HMIPC_NAME: reauth_entry.data.get(HMIPC_NAME),
|
||||
}
|
||||
self.auth = HomematicipAuth(self.hass, config)
|
||||
connected = await self.auth.async_setup()
|
||||
if connected:
|
||||
return await self.async_step_link()
|
||||
errors["base"] = "invalid_sgtin_or_pin"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(HMIPC_PIN): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, str]) -> ConfigFlowResult:
|
||||
"""Import a new access point as a config entry."""
|
||||
hapid = import_data[HMIPC_HAPID].replace("-", "").upper()
|
||||
|
||||
@@ -12,10 +12,7 @@ from homematicip.auth import Auth
|
||||
from homematicip.base.enums import EventType
|
||||
from homematicip.connection.connection_context import ConnectionContextBuilder
|
||||
from homematicip.connection.rest_connection import RestConnection
|
||||
from homematicip.exceptions.connection_exceptions import (
|
||||
HmipAuthenticationError,
|
||||
HmipConnectionError,
|
||||
)
|
||||
from homematicip.exceptions.connection_exceptions import HmipConnectionError
|
||||
|
||||
import homeassistant
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -195,12 +192,6 @@ class HomematicipHAP:
|
||||
try:
|
||||
await self.get_state()
|
||||
break
|
||||
except HmipAuthenticationError:
|
||||
_LOGGER.error(
|
||||
"Authentication error from HomematicIP Cloud, triggering reauth"
|
||||
)
|
||||
self.config_entry.async_start_reauth(self.hass)
|
||||
break
|
||||
except HmipConnectionError as err:
|
||||
_LOGGER.warning(
|
||||
"Get_state failed, retrying in %s seconds: %s", delay, err
|
||||
|
||||
@@ -55,7 +55,7 @@ async def async_setup_entry(
|
||||
entities: list[HomematicipGenericEntity] = []
|
||||
|
||||
entities.extend(
|
||||
HomematicipColorLight(hap, d, ch.index)
|
||||
HomematicipLightHS(hap, d, ch.index)
|
||||
for d in hap.home.devices
|
||||
for ch in d.functionalChannels
|
||||
if ch.functionalChannelType == FunctionalChannelType.UNIVERSAL_LIGHT_CHANNEL
|
||||
@@ -136,32 +136,16 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity):
|
||||
await self._device.turn_off_async()
|
||||
|
||||
|
||||
class HomematicipColorLight(HomematicipGenericEntity, LightEntity):
|
||||
"""Representation of the HomematicIP color light."""
|
||||
class HomematicipLightHS(HomematicipGenericEntity, LightEntity):
|
||||
"""Representation of the HomematicIP light with HS color mode."""
|
||||
|
||||
_attr_color_mode = ColorMode.HS
|
||||
_attr_supported_color_modes = {ColorMode.HS}
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None:
|
||||
"""Initialize the light entity."""
|
||||
super().__init__(hap, device, channel=channel_index, is_multi_channel=True)
|
||||
|
||||
def _supports_color(self) -> bool:
|
||||
"""Return true if device supports hue/saturation color control."""
|
||||
channel = self.get_channel_or_raise()
|
||||
return channel.hue is not None and channel.saturationLevel is not None
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self._supports_color():
|
||||
return ColorMode.HS
|
||||
return ColorMode.BRIGHTNESS
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[ColorMode]:
|
||||
"""Return the supported color modes."""
|
||||
if self._supports_color():
|
||||
return {ColorMode.HS}
|
||||
return {ColorMode.BRIGHTNESS}
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if light is on."""
|
||||
@@ -188,26 +172,18 @@ class HomematicipColorLight(HomematicipGenericEntity, LightEntity):
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
channel = self.get_channel_or_raise()
|
||||
hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0))
|
||||
hue = hs_color[0] % 360.0
|
||||
saturation = hs_color[1] / 100.0
|
||||
dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2)
|
||||
|
||||
if ATTR_HS_COLOR not in kwargs:
|
||||
hue = channel.hue
|
||||
saturation = channel.saturationLevel
|
||||
|
||||
if ATTR_BRIGHTNESS not in kwargs:
|
||||
# If no brightness is set, use the current brightness
|
||||
dim_level = channel.dimLevel or 1.0
|
||||
|
||||
# Use dim-only method for monochrome mode (hue/saturation not supported)
|
||||
if not self._supports_color():
|
||||
await channel.set_dim_level_async(dim_level=dim_level)
|
||||
return
|
||||
|
||||
# Full color mode with hue/saturation
|
||||
if ATTR_HS_COLOR in kwargs:
|
||||
hs_color = kwargs[ATTR_HS_COLOR]
|
||||
hue = hs_color[0] % 360.0
|
||||
saturation = hs_color[1] / 100.0
|
||||
else:
|
||||
hue = channel.hue
|
||||
saturation = channel.saturationLevel
|
||||
|
||||
await channel.set_hue_saturation_dim_level_async(
|
||||
hue=hue, saturation_level=saturation, dim_level=dim_level
|
||||
)
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"connection_aborted": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"error": {
|
||||
"connection_aborted": "Registration failed, please try again.",
|
||||
"invalid_sgtin_or_pin": "Invalid SGTIN or PIN code, please try again.",
|
||||
"press_the_button": "Please press the blue button.",
|
||||
"register_failed": "Failed to register, please try again.",
|
||||
@@ -26,13 +24,6 @@
|
||||
"link": {
|
||||
"description": "Press the blue button on the access point and the **Submit** button to register Homematic IP with Home Assistant.\n\n",
|
||||
"title": "Link access point"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"pin": "[%key:common::config_flow::data::pin%]"
|
||||
},
|
||||
"description": "The authentication token for your HomematicIP access point is no longer valid. Press **Submit** and then press the blue button on your access point to re-register.",
|
||||
"title": "Re-authenticate HomematicIP access point"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,7 +10,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.SELECT,
|
||||
Platform.NUMBER,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:
|
||||
|
||||
132
homeassistant/components/homevolt/number.py
Normal file
132
homeassistant/components/homevolt/number.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Support for Homevolt number entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberEntityDescription
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
|
||||
from .entity import HomevoltEntity, homevolt_exception_handler
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomevoltNumberEntityDescription(NumberEntityDescription):
|
||||
"""Custom entity description for Homevolt numbers."""
|
||||
|
||||
set_value_fn: Any = None
|
||||
value_fn: Any = None
|
||||
|
||||
|
||||
NUMBER_DESCRIPTIONS: tuple[HomevoltNumberEntityDescription, ...] = (
|
||||
HomevoltNumberEntityDescription(
|
||||
key="setpoint",
|
||||
translation_key="setpoint",
|
||||
native_min_value=0,
|
||||
native_max_value=20000,
|
||||
native_step=100,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
HomevoltNumberEntityDescription(
|
||||
key="max_charge",
|
||||
translation_key="max_charge",
|
||||
native_min_value=0,
|
||||
native_max_value=20000,
|
||||
native_step=100,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
HomevoltNumberEntityDescription(
|
||||
key="max_discharge",
|
||||
translation_key="max_discharge",
|
||||
native_min_value=0,
|
||||
native_max_value=20000,
|
||||
native_step=100,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
HomevoltNumberEntityDescription(
|
||||
key="min_soc",
|
||||
translation_key="min_soc",
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
HomevoltNumberEntityDescription(
|
||||
key="max_soc",
|
||||
translation_key="max_soc",
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
HomevoltNumberEntityDescription(
|
||||
key="grid_import_limit",
|
||||
translation_key="grid_import_limit",
|
||||
native_min_value=0,
|
||||
native_max_value=20000,
|
||||
native_step=100,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
HomevoltNumberEntityDescription(
|
||||
key="grid_export_limit",
|
||||
translation_key="grid_export_limit",
|
||||
native_min_value=0,
|
||||
native_max_value=20000,
|
||||
native_step=100,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomevoltConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Homevolt number entities."""
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[HomevoltNumberEntity] = []
|
||||
for description in NUMBER_DESCRIPTIONS:
|
||||
entities.append(HomevoltNumberEntity(coordinator, description))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HomevoltNumberEntity(HomevoltEntity, NumberEntity):
|
||||
"""Representation of a Homevolt number entity."""
|
||||
|
||||
entity_description: HomevoltNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomevoltDataUpdateCoordinator,
|
||||
description: HomevoltNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the number entity."""
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.data.unique_id}_{description.key}"
|
||||
device_id = coordinator.data.unique_id
|
||||
super().__init__(coordinator, f"ems_{device_id}")
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value."""
|
||||
value = self.coordinator.client.schedule.get(self.entity_description.key)
|
||||
return float(value) if value is not None else None
|
||||
|
||||
@homevolt_exception_handler
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the value."""
|
||||
key = self.entity_description.key
|
||||
await self.coordinator.client.set_battery_parameters(**{key: int(value)})
|
||||
await self.coordinator.async_request_refresh()
|
||||
51
homeassistant/components/homevolt/select.py
Normal file
51
homeassistant/components/homevolt/select.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Support for Homevolt select entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homevolt.const import SCHEDULE_TYPE
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
|
||||
from .entity import HomevoltEntity, homevolt_exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 0 # Coordinator-based updates
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomevoltConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Homevolt select entities."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities([HomevoltModeSelect(coordinator)])
|
||||
|
||||
|
||||
class HomevoltModeSelect(HomevoltEntity, SelectEntity):
|
||||
"""Select entity for battery operational mode."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "battery_mode"
|
||||
_attr_options = list(SCHEDULE_TYPE.values())
|
||||
|
||||
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
|
||||
"""Initialize the select entity."""
|
||||
self._attr_unique_id = f"{coordinator.data.unique_id}_battery_mode"
|
||||
device_id = coordinator.data.unique_id
|
||||
super().__init__(coordinator, f"ems_{device_id}")
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the current selected mode."""
|
||||
mode_int = self.coordinator.client.schedule_mode
|
||||
return SCHEDULE_TYPE.get(mode_int)
|
||||
|
||||
@homevolt_exception_handler
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected mode."""
|
||||
await self.coordinator.client.set_battery_mode(mode=option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -54,6 +54,46 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"number": {
|
||||
"grid_export_limit": {
|
||||
"name": "Grid export limit"
|
||||
},
|
||||
"grid_import_limit": {
|
||||
"name": "Grid import limit"
|
||||
},
|
||||
"max_charge": {
|
||||
"name": "Maximum charge power"
|
||||
},
|
||||
"max_discharge": {
|
||||
"name": "Maximum discharge power"
|
||||
},
|
||||
"max_soc": {
|
||||
"name": "Maximum state of charge"
|
||||
},
|
||||
"min_soc": {
|
||||
"name": "Minimum state of charge"
|
||||
},
|
||||
"setpoint": {
|
||||
"name": "Power setpoint"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"battery_mode": {
|
||||
"name": "Battery mode",
|
||||
"state": {
|
||||
"frequency_reserve": "Frequency reserve",
|
||||
"full_solar_export": "Full solar export",
|
||||
"grid_charge": "Grid charge",
|
||||
"grid_charge_discharge": "Grid charge/discharge",
|
||||
"grid_discharge": "Grid discharge",
|
||||
"idle": "Idle",
|
||||
"inverter_charge": "Inverter charge",
|
||||
"inverter_discharge": "Inverter discharge",
|
||||
"solar_charge": "Solar charge",
|
||||
"solar_charge_discharge": "Solar charge/discharge"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"available_charging_energy": {
|
||||
"name": "Available charging energy"
|
||||
|
||||
@@ -10,7 +10,6 @@ override_schedule:
|
||||
selector:
|
||||
duration:
|
||||
enable_day: true
|
||||
enable_second: false
|
||||
override_mode:
|
||||
required: true
|
||||
example: "mow"
|
||||
@@ -33,7 +32,6 @@ override_schedule_work_area:
|
||||
selector:
|
||||
duration:
|
||||
enable_day: true
|
||||
enable_second: false
|
||||
work_area_id:
|
||||
required: true
|
||||
example: "123"
|
||||
|
||||
@@ -511,7 +511,7 @@
|
||||
"description": "Lets the mower either mow or park for a given duration, overriding all schedules.",
|
||||
"fields": {
|
||||
"duration": {
|
||||
"description": "Minimum: 1 minute, maximum: 42 days.",
|
||||
"description": "Minimum: 1 minute, maximum: 42 days, seconds will be ignored.",
|
||||
"name": "Duration"
|
||||
},
|
||||
"override_mode": {
|
||||
|
||||
@@ -7,12 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import IndevoltConfigEntry, IndevoltCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool:
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Select platform for Indevolt integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import IndevoltConfigEntry
|
||||
from .coordinator import IndevoltCoordinator
|
||||
from .entity import IndevoltEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class IndevoltSelectEntityDescription(SelectEntityDescription):
|
||||
"""Custom entity description class for Indevolt select entities."""
|
||||
|
||||
read_key: str
|
||||
write_key: str
|
||||
value_to_option: dict[int, str]
|
||||
unavailable_values: list[int] = field(default_factory=list)
|
||||
generation: list[int] = field(default_factory=lambda: [1, 2])
|
||||
|
||||
|
||||
SELECTS: Final = (
|
||||
IndevoltSelectEntityDescription(
|
||||
key="energy_mode",
|
||||
translation_key="energy_mode",
|
||||
read_key="7101",
|
||||
write_key="47005",
|
||||
value_to_option={
|
||||
1: "self_consumed_prioritized",
|
||||
4: "real_time_control",
|
||||
5: "charge_discharge_schedule",
|
||||
},
|
||||
unavailable_values=[0],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: IndevoltConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the select platform for Indevolt."""
|
||||
coordinator = entry.runtime_data
|
||||
device_gen = coordinator.generation
|
||||
|
||||
# Select initialization
|
||||
async_add_entities(
|
||||
IndevoltSelectEntity(coordinator=coordinator, description=description)
|
||||
for description in SELECTS
|
||||
if device_gen in description.generation
|
||||
)
|
||||
|
||||
|
||||
class IndevoltSelectEntity(IndevoltEntity, SelectEntity):
|
||||
"""Represents a select entity for Indevolt devices."""
|
||||
|
||||
entity_description: IndevoltSelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: IndevoltCoordinator,
|
||||
description: IndevoltSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Indevolt select entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self.serial_number}_{description.key}"
|
||||
self._attr_options = list(description.value_to_option.values())
|
||||
self._option_to_value = {v: k for k, v in description.value_to_option.items()}
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the currently selected option."""
|
||||
raw_value = self.coordinator.data.get(self.entity_description.read_key)
|
||||
if raw_value is None:
|
||||
return None
|
||||
|
||||
return self.entity_description.value_to_option.get(raw_value)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return False when the device is in a mode that cannot be selected."""
|
||||
if not super().available:
|
||||
return False
|
||||
|
||||
raw_value = self.coordinator.data.get(self.entity_description.read_key)
|
||||
return raw_value not in self.entity_description.unavailable_values
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Select a new option."""
|
||||
value = self._option_to_value[option]
|
||||
success = await self.coordinator.async_push_data(
|
||||
self.entity_description.write_key, value
|
||||
)
|
||||
|
||||
if success:
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
else:
|
||||
raise HomeAssistantError(f"Failed to set option {option} for {self.name}")
|
||||
@@ -37,16 +37,6 @@
|
||||
"name": "Max AC output power"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"energy_mode": {
|
||||
"name": "[%key:component::indevolt::entity::sensor::energy_mode::name%]",
|
||||
"state": {
|
||||
"charge_discharge_schedule": "[%key:component::indevolt::entity::sensor::energy_mode::state::charge_discharge_schedule%]",
|
||||
"real_time_control": "[%key:component::indevolt::entity::sensor::energy_mode::state::real_time_control%]",
|
||||
"self_consumed_prioritized": "[%key:component::indevolt::entity::sensor::energy_mode::state::self_consumed_prioritized%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ac_input_power": {
|
||||
"name": "AC input power"
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from pyliebherrhomeapi import LiebherrClient
|
||||
from pyliebherrhomeapi.exceptions import (
|
||||
@@ -16,13 +14,8 @@ from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from .const import DEVICE_SCAN_INTERVAL, DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator, LiebherrData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.NUMBER,
|
||||
@@ -49,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
|
||||
raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err
|
||||
|
||||
# Create a coordinator for each device (may be empty if no devices)
|
||||
data = LiebherrData(client=client)
|
||||
coordinators: dict[str, LiebherrCoordinator] = {}
|
||||
for device in devices:
|
||||
coordinator = LiebherrCoordinator(
|
||||
hass=hass,
|
||||
@@ -57,61 +50,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
|
||||
client=client,
|
||||
device_id=device.device_id,
|
||||
)
|
||||
data.coordinators[device.device_id] = coordinator
|
||||
coordinators[device.device_id] = coordinator
|
||||
|
||||
await asyncio.gather(
|
||||
*(
|
||||
coordinator.async_config_entry_first_refresh()
|
||||
for coordinator in data.coordinators.values()
|
||||
for coordinator in coordinators.values()
|
||||
)
|
||||
)
|
||||
|
||||
# Store runtime data
|
||||
entry.runtime_data = data
|
||||
# Store coordinators in runtime data
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Schedule periodic scan for new devices
|
||||
async def _async_scan_for_new_devices(_now: datetime) -> None:
|
||||
"""Scan for new devices added to the account."""
|
||||
try:
|
||||
devices = await client.get_devices()
|
||||
except LiebherrAuthenticationError, LiebherrConnectionError:
|
||||
_LOGGER.debug("Failed to scan for new devices")
|
||||
return
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error scanning for new devices")
|
||||
return
|
||||
|
||||
new_coordinators: list[LiebherrCoordinator] = []
|
||||
for device in devices:
|
||||
if device.device_id not in data.coordinators:
|
||||
coordinator = LiebherrCoordinator(
|
||||
hass=hass,
|
||||
config_entry=entry,
|
||||
client=client,
|
||||
device_id=device.device_id,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
if not coordinator.last_update_success:
|
||||
_LOGGER.debug("Failed to set up new device %s", device.device_id)
|
||||
continue
|
||||
data.coordinators[device.device_id] = coordinator
|
||||
new_coordinators.append(coordinator)
|
||||
|
||||
if new_coordinators:
|
||||
async_dispatcher_send(
|
||||
hass,
|
||||
f"{DOMAIN}_new_device_{entry.entry_id}",
|
||||
new_coordinators,
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(
|
||||
hass, _async_scan_for_new_devices, DEVICE_SCAN_INTERVAL
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,4 @@ from typing import Final
|
||||
DOMAIN: Final = "liebherr"
|
||||
MANUFACTURER: Final = "Liebherr"
|
||||
|
||||
SCAN_INTERVAL: Final = timedelta(seconds=60)
|
||||
DEVICE_SCAN_INTERVAL: Final = timedelta(minutes=5)
|
||||
REFRESH_DELAY: Final = timedelta(seconds=5)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyliebherrhomeapi import (
|
||||
@@ -18,20 +18,13 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
from .const import DOMAIN
|
||||
|
||||
type LiebherrConfigEntry = ConfigEntry[dict[str, LiebherrCoordinator]]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiebherrData:
|
||||
"""Runtime data for the Liebherr integration."""
|
||||
|
||||
client: LiebherrClient
|
||||
coordinators: dict[str, LiebherrCoordinator] = field(default_factory=dict)
|
||||
|
||||
|
||||
type LiebherrConfigEntry = ConfigEntry[LiebherrData]
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]):
|
||||
|
||||
@@ -29,6 +29,6 @@ async def async_get_config_entry_diagnostics(
|
||||
},
|
||||
"data": asdict(coordinator.data),
|
||||
}
|
||||
for device_id, coordinator in entry.runtime_data.coordinators.items()
|
||||
for device_id, coordinator in entry.runtime_data.items()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,11 +16,9 @@ from homeassistant.components.number import (
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import LiebherrZoneEntity
|
||||
|
||||
@@ -55,41 +53,22 @@ NUMBER_TYPES: tuple[LiebherrNumberEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _create_number_entities(
|
||||
coordinators: list[LiebherrCoordinator],
|
||||
) -> list[LiebherrNumber]:
|
||||
"""Create number entities for the given coordinators."""
|
||||
return [
|
||||
LiebherrNumber(
|
||||
coordinator=coordinator,
|
||||
zone_id=temp_control.zone_id,
|
||||
description=description,
|
||||
)
|
||||
for coordinator in coordinators
|
||||
for temp_control in coordinator.data.get_temperature_controls().values()
|
||||
for description in NUMBER_TYPES
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr number entities."""
|
||||
coordinators = entry.runtime_data
|
||||
async_add_entities(
|
||||
_create_number_entities(list(entry.runtime_data.coordinators.values()))
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
|
||||
"""Add number entities for new devices."""
|
||||
async_add_entities(_create_number_entities(coordinators))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
|
||||
LiebherrNumber(
|
||||
coordinator=coordinator,
|
||||
zone_id=temp_control.zone_id,
|
||||
description=description,
|
||||
)
|
||||
for coordinator in coordinators.values()
|
||||
for temp_control in coordinator.data.get_temperature_controls().values()
|
||||
for description in NUMBER_TYPES
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
|
||||
@@ -18,11 +18,9 @@ from pyliebherrhomeapi import (
|
||||
)
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import ZONE_POSITION_MAP, LiebherrEntity
|
||||
|
||||
@@ -111,13 +109,15 @@ SELECT_TYPES: list[LiebherrSelectEntityDescription] = [
|
||||
]
|
||||
|
||||
|
||||
def _create_select_entities(
|
||||
coordinators: list[LiebherrCoordinator],
|
||||
) -> list[LiebherrSelectEntity]:
|
||||
"""Create select entities for the given coordinators."""
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr select entities."""
|
||||
entities: list[LiebherrSelectEntity] = []
|
||||
|
||||
for coordinator in coordinators:
|
||||
for coordinator in entry.runtime_data.values():
|
||||
has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
|
||||
|
||||
for control in coordinator.data.controls:
|
||||
@@ -137,29 +137,7 @@ def _create_select_entities(
|
||||
)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr select entities."""
|
||||
async_add_entities(
|
||||
_create_select_entities(list(entry.runtime_data.coordinators.values()))
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
|
||||
"""Add select entities for new devices."""
|
||||
async_add_entities(_create_select_entities(coordinators))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class LiebherrSelectEntity(LiebherrEntity, SelectEntity):
|
||||
|
||||
@@ -14,12 +14,10 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import LiebherrZoneEntity
|
||||
|
||||
@@ -50,41 +48,22 @@ SENSOR_TYPES: tuple[LiebherrSensorEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _create_sensor_entities(
|
||||
coordinators: list[LiebherrCoordinator],
|
||||
) -> list[LiebherrSensor]:
|
||||
"""Create sensor entities for the given coordinators."""
|
||||
return [
|
||||
LiebherrSensor(
|
||||
coordinator=coordinator,
|
||||
zone_id=temp_control.zone_id,
|
||||
description=description,
|
||||
)
|
||||
for coordinator in coordinators
|
||||
for temp_control in coordinator.data.get_temperature_controls().values()
|
||||
for description in SENSOR_TYPES
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr sensor entities."""
|
||||
coordinators = entry.runtime_data
|
||||
async_add_entities(
|
||||
_create_sensor_entities(list(entry.runtime_data.coordinators.values()))
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
|
||||
"""Add sensor entities for new devices."""
|
||||
async_add_entities(_create_sensor_entities(coordinators))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
|
||||
LiebherrSensor(
|
||||
coordinator=coordinator,
|
||||
zone_id=temp_control.zone_id,
|
||||
description=description,
|
||||
)
|
||||
for coordinator in coordinators.values()
|
||||
for temp_control in coordinator.data.get_temperature_controls().values()
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,11 +15,9 @@ from pyliebherrhomeapi.const import (
|
||||
)
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import ZONE_POSITION_MAP, LiebherrEntity
|
||||
|
||||
@@ -92,13 +90,15 @@ DEVICE_SWITCH_TYPES: dict[str, LiebherrDeviceSwitchEntityDescription] = {
|
||||
}
|
||||
|
||||
|
||||
def _create_switch_entities(
|
||||
coordinators: list[LiebherrCoordinator],
|
||||
) -> list[LiebherrDeviceSwitch | LiebherrZoneSwitch]:
|
||||
"""Create switch entities for the given coordinators."""
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr switch entities."""
|
||||
entities: list[LiebherrDeviceSwitch | LiebherrZoneSwitch] = []
|
||||
|
||||
for coordinator in coordinators:
|
||||
for coordinator in entry.runtime_data.values():
|
||||
has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
|
||||
|
||||
for control in coordinator.data.controls:
|
||||
@@ -127,29 +127,7 @@ def _create_switch_entities(
|
||||
)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr switch entities."""
|
||||
async_add_entities(
|
||||
_create_switch_entities(list(entry.runtime_data.coordinators.values()))
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
|
||||
"""Add switch entities for new devices."""
|
||||
async_add_entities(_create_switch_entities(coordinators))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class LiebherrDeviceSwitch(LiebherrEntity, SwitchEntity):
|
||||
|
||||
@@ -285,9 +285,9 @@ class MatterEntity(Entity):
|
||||
self,
|
||||
command: ClusterCommand,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
) -> None:
|
||||
"""Send device command on the primary attribute's endpoint."""
|
||||
return await self.matter_client.send_device_command(
|
||||
await self.matter_client.send_device_command(
|
||||
node_id=self._endpoint.node.node_id,
|
||||
endpoint_id=self._endpoint.endpoint_id,
|
||||
command=command,
|
||||
|
||||
@@ -10,7 +10,6 @@ from chip.clusters import Objects as clusters
|
||||
from matter_server.client.models import device_types
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
Segment,
|
||||
StateVacuumEntity,
|
||||
StateVacuumEntityDescription,
|
||||
VacuumActivity,
|
||||
@@ -71,7 +70,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
"""Representation of a Matter Vacuum cleaner entity."""
|
||||
|
||||
_last_accepted_commands: list[int] | None = None
|
||||
_last_service_area_feature_map: int | None = None
|
||||
_supported_run_modes: (
|
||||
dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None
|
||||
) = None
|
||||
@@ -138,16 +136,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
"No supported run mode found to start the vacuum cleaner."
|
||||
)
|
||||
|
||||
# Reset selected areas to an unconstrained selection to ensure start
|
||||
# performs a full clean and does not reuse a previous area-targeted
|
||||
# selection.
|
||||
if VacuumEntityFeature.CLEAN_AREA in self.supported_features:
|
||||
# Matter ServiceArea: an empty NewAreas list means unconstrained
|
||||
# operation (full clean).
|
||||
await self.send_device_command(
|
||||
clusters.ServiceArea.Commands.SelectAreas(newAreas=[])
|
||||
)
|
||||
|
||||
await self.send_device_command(
|
||||
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
|
||||
)
|
||||
@@ -156,66 +144,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
"""Pause the cleaning task."""
|
||||
await self.send_device_command(clusters.RvcOperationalState.Commands.Pause())
|
||||
|
||||
@property
|
||||
def _current_segments(self) -> dict[str, Segment]:
|
||||
"""Return the current cleanable segments reported by the device."""
|
||||
supported_areas: list[clusters.ServiceArea.Structs.AreaStruct] = (
|
||||
self.get_matter_attribute_value(
|
||||
clusters.ServiceArea.Attributes.SupportedAreas
|
||||
)
|
||||
)
|
||||
|
||||
segments: dict[str, Segment] = {}
|
||||
for area in supported_areas:
|
||||
area_name = None
|
||||
if area.areaInfo and area.areaInfo.locationInfo:
|
||||
area_name = area.areaInfo.locationInfo.locationName
|
||||
|
||||
if area_name:
|
||||
segment_id = str(area.areaID)
|
||||
segments[segment_id] = Segment(id=segment_id, name=area_name)
|
||||
|
||||
return segments
|
||||
|
||||
async def async_get_segments(self) -> list[Segment]:
|
||||
"""Get the segments that can be cleaned.
|
||||
|
||||
Returns a list of segments containing their ids and names.
|
||||
"""
|
||||
return list(self._current_segments.values())
|
||||
|
||||
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
|
||||
"""Clean the specified segments.
|
||||
|
||||
Args:
|
||||
segment_ids: List of segment IDs to clean.
|
||||
**kwargs: Additional arguments (unused).
|
||||
|
||||
"""
|
||||
area_ids = [int(segment_id) for segment_id in segment_ids]
|
||||
|
||||
mode = self._get_run_mode_by_tag(ModeTag.CLEANING)
|
||||
if mode is None:
|
||||
raise HomeAssistantError(
|
||||
"No supported run mode found to start the vacuum cleaner."
|
||||
)
|
||||
|
||||
response = await self.send_device_command(
|
||||
clusters.ServiceArea.Commands.SelectAreas(newAreas=area_ids)
|
||||
)
|
||||
|
||||
if (
|
||||
response
|
||||
and response.status != clusters.ServiceArea.Enums.SelectAreasStatus.kSuccess
|
||||
):
|
||||
raise HomeAssistantError(
|
||||
f"Failed to select areas: {response.statusText or response.status.name}"
|
||||
)
|
||||
|
||||
await self.send_device_command(
|
||||
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_from_device(self) -> None:
|
||||
"""Update from device."""
|
||||
@@ -248,34 +176,16 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
state = VacuumActivity.CLEANING
|
||||
self._attr_activity = state
|
||||
|
||||
if (
|
||||
VacuumEntityFeature.CLEAN_AREA in self.supported_features
|
||||
and self.registry_entry is not None
|
||||
and (last_seen_segments := self.last_seen_segments) is not None
|
||||
and self._current_segments != {s.id: s for s in last_seen_segments}
|
||||
):
|
||||
self.async_create_segments_issue()
|
||||
|
||||
@callback
|
||||
def _calculate_features(self) -> None:
|
||||
"""Calculate features for HA Vacuum platform."""
|
||||
accepted_operational_commands: list[int] = self.get_matter_attribute_value(
|
||||
clusters.RvcOperationalState.Attributes.AcceptedCommandList
|
||||
)
|
||||
service_area_feature_map: int | None = self.get_matter_attribute_value(
|
||||
clusters.ServiceArea.Attributes.FeatureMap
|
||||
)
|
||||
|
||||
# In principle the feature set should not change, except for accepted
|
||||
# commands and service area feature map.
|
||||
if (
|
||||
self._last_accepted_commands == accepted_operational_commands
|
||||
and self._last_service_area_feature_map == service_area_feature_map
|
||||
):
|
||||
# in principle the feature set should not change, except for the accepted commands
|
||||
if self._last_accepted_commands == accepted_operational_commands:
|
||||
return
|
||||
|
||||
self._last_accepted_commands = accepted_operational_commands
|
||||
self._last_service_area_feature_map = service_area_feature_map
|
||||
supported_features: VacuumEntityFeature = VacuumEntityFeature(0)
|
||||
supported_features |= VacuumEntityFeature.START
|
||||
supported_features |= VacuumEntityFeature.STATE
|
||||
@@ -302,12 +212,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
in accepted_operational_commands
|
||||
):
|
||||
supported_features |= VacuumEntityFeature.RETURN_HOME
|
||||
# Check if Map feature is enabled for clean area support
|
||||
if (
|
||||
service_area_feature_map is not None
|
||||
and service_area_feature_map & clusters.ServiceArea.Bitmaps.Feature.kMaps
|
||||
):
|
||||
supported_features |= VacuumEntityFeature.CLEAN_AREA
|
||||
|
||||
self._attr_supported_features = supported_features
|
||||
|
||||
@@ -324,10 +228,6 @@ DISCOVERY_SCHEMAS = [
|
||||
clusters.RvcRunMode.Attributes.CurrentMode,
|
||||
clusters.RvcOperationalState.Attributes.OperationalState,
|
||||
),
|
||||
optional_attributes=(
|
||||
clusters.ServiceArea.Attributes.FeatureMap,
|
||||
clusters.ServiceArea.Attributes.SupportedAreas,
|
||||
),
|
||||
device_type=(device_types.RoboticVacuumCleaner,),
|
||||
allow_none_value=True,
|
||||
),
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable, Mapping
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
@@ -43,48 +41,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
# Headers and regex for WWW-Authenticate parsing for rfc9728
|
||||
WWW_AUTHENTICATE_HEADER = "WWW-Authenticate"
|
||||
RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"'
|
||||
OAUTH_PROTECTED_RESOURCE_ENDPOINT = "/.well-known/oauth-protected-resource"
|
||||
SCOPES_REGEXP = r'scope="([^"]+)"'
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthenticateHeader:
|
||||
"""Class to hold info from the WWW-Authenticate header for supporting rfc9728."""
|
||||
|
||||
resource_metadata_url: str
|
||||
scopes: list[str] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_header(
|
||||
cls, url: str, error_response: httpx.Response
|
||||
) -> AuthenticateHeader | None:
|
||||
"""Create AuthenticateHeader from WWW-Authenticate header."""
|
||||
if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not (
|
||||
match := re.search(RESOURCE_METADATA_REGEXP, header)
|
||||
):
|
||||
return None
|
||||
resource_metadata_url = str(URL(url).join(URL(match.group(1))))
|
||||
scope_match = re.search(SCOPES_REGEXP, header)
|
||||
return cls(
|
||||
resource_metadata_url=resource_metadata_url,
|
||||
scopes=scope_match.group(1).split(" ") if scope_match else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceMetadata:
|
||||
"""Class to hold protected resource metadata defined in rfc9728."""
|
||||
|
||||
authorization_servers: list[str]
|
||||
"""List of authorization server URLs."""
|
||||
|
||||
supported_scopes: list[str] | None = None
|
||||
"""List of supported scopes."""
|
||||
|
||||
|
||||
# OAuth server discovery endpoint for rfc8414
|
||||
OAUTH_DISCOVERY_ENDPOINT = ".well-known/oauth-authorization-server"
|
||||
MCP_DISCOVERY_HEADERS = {
|
||||
@@ -102,27 +58,40 @@ class OAuthConfig:
|
||||
scopes: list[str] | None = None
|
||||
|
||||
|
||||
async def async_discover_authorization_server(
|
||||
hass: HomeAssistant, auth_server_url: str
|
||||
async def async_discover_oauth_config(
|
||||
hass: HomeAssistant, mcp_server_url: str
|
||||
) -> OAuthConfig:
|
||||
"""Perform OAuth 2.0 Authorization Server Metadata discovery as per RFC8414."""
|
||||
parsed_url = URL(auth_server_url)
|
||||
urls_to_try = [
|
||||
str(parsed_url.with_path(path))
|
||||
for path in _authorization_server_discovery_paths(parsed_url)
|
||||
]
|
||||
# Pick any successful response and propagate exceptions except for
|
||||
# 404 where we fall back to assuming some default paths.
|
||||
"""Discover the OAuth configuration for the MCP server.
|
||||
|
||||
This implements the functionality in the MCP spec for discovery. If the MCP server URL
|
||||
is https://api.example.com/v1/mcp, then:
|
||||
- The authorization base URL is https://api.example.com
|
||||
- The metadata endpoint MUST be at https://api.example.com/.well-known/oauth-authorization-server
|
||||
- For servers that do not implement OAuth 2.0 Authorization Server Metadata, the client uses
|
||||
default paths relative to the authorization base URL.
|
||||
"""
|
||||
parsed_url = URL(mcp_server_url)
|
||||
discovery_endpoint = str(parsed_url.with_path(OAUTH_DISCOVERY_ENDPOINT))
|
||||
try:
|
||||
response = await _async_fetch_any(hass, urls_to_try)
|
||||
except NotFoundError:
|
||||
_LOGGER.info("Authorization Server Metadata not found, using default paths")
|
||||
return OAuthConfig(
|
||||
authorization_server=AuthorizationServer(
|
||||
authorize_url=str(parsed_url.with_path("/authorize")),
|
||||
token_url=str(parsed_url.with_path("/token")),
|
||||
async with httpx.AsyncClient(headers=MCP_DISCOVERY_HEADERS) as client:
|
||||
response = await client.get(discovery_endpoint)
|
||||
response.raise_for_status()
|
||||
except httpx.TimeoutException as error:
|
||||
_LOGGER.info("Timeout connecting to MCP server: %s", error)
|
||||
raise TimeoutConnectError from error
|
||||
except httpx.HTTPStatusError as error:
|
||||
if error.response.status_code == 404:
|
||||
_LOGGER.info("Authorization Server Metadata not found, using default paths")
|
||||
return OAuthConfig(
|
||||
authorization_server=AuthorizationServer(
|
||||
authorize_url=str(parsed_url.with_path("/authorize")),
|
||||
token_url=str(parsed_url.with_path("/token")),
|
||||
)
|
||||
)
|
||||
)
|
||||
raise CannotConnect from error
|
||||
except httpx.HTTPError as error:
|
||||
_LOGGER.info("Cannot discover OAuth configuration: %s", error)
|
||||
raise CannotConnect from error
|
||||
|
||||
data = response.json()
|
||||
authorize_url = data["authorization_endpoint"]
|
||||
@@ -161,8 +130,7 @@ async def validate_input(
|
||||
except httpx.HTTPStatusError as error:
|
||||
_LOGGER.info("Cannot connect to MCP server: %s", error)
|
||||
if error.response.status_code == 401:
|
||||
auth_header = AuthenticateHeader.from_header(url, error.response)
|
||||
raise InvalidAuth(auth_header) from error
|
||||
raise InvalidAuth from error
|
||||
raise CannotConnect from error
|
||||
except httpx.HTTPError as error:
|
||||
_LOGGER.info("Cannot connect to MCP server: %s", error)
|
||||
@@ -188,7 +156,6 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
super().__init__()
|
||||
self.data: dict[str, Any] = {}
|
||||
self.oauth_config: OAuthConfig | None = None
|
||||
self.auth_header: AuthenticateHeader | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -204,8 +171,7 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
errors["base"] = "timeout_connect"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth as err:
|
||||
self.auth_header = err.metadata
|
||||
except InvalidAuth:
|
||||
self.data[CONF_URL] = user_input[CONF_URL]
|
||||
return await self.async_step_auth_discovery()
|
||||
except MissingCapabilities:
|
||||
@@ -230,34 +196,12 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Handle the OAuth server discovery step.
|
||||
|
||||
Since this OAuth server requires authentication, this step will attempt
|
||||
to find the OAuth metadata then run the OAuth authentication flow.
|
||||
to find the OAuth medata then run the OAuth authentication flow.
|
||||
"""
|
||||
resource_metadata: ResourceMetadata | None = None
|
||||
try:
|
||||
if self.auth_header:
|
||||
_LOGGER.debug(
|
||||
"Resource metadata discovery from header: %s", self.auth_header
|
||||
)
|
||||
resource_metadata = await async_discover_protected_resource(
|
||||
self.hass,
|
||||
self.auth_header.resource_metadata_url,
|
||||
self.data[CONF_URL],
|
||||
)
|
||||
_LOGGER.debug("Protected resource metadata: %s", resource_metadata)
|
||||
oauth_config = await async_discover_authorization_server(
|
||||
self.hass,
|
||||
# Use the first authorization server from the resource metadata as it
|
||||
# is the most common to have only one and there is not a defined strategy.
|
||||
resource_metadata.authorization_servers[0],
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Discovering authorization server without protected resource metadata"
|
||||
)
|
||||
oauth_config = await async_discover_authorization_server(
|
||||
self.hass,
|
||||
self.data[CONF_URL],
|
||||
)
|
||||
oauth_config = await async_discover_oauth_config(
|
||||
self.hass, self.data[CONF_URL]
|
||||
)
|
||||
except TimeoutConnectError:
|
||||
return self.async_abort(reason="timeout_connect")
|
||||
except CannotConnect:
|
||||
@@ -272,9 +216,7 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
{
|
||||
CONF_AUTHORIZATION_URL: oauth_config.authorization_server.authorize_url,
|
||||
CONF_TOKEN_URL: oauth_config.authorization_server.token_url,
|
||||
CONF_SCOPE: _select_scopes(
|
||||
self.auth_header, oauth_config, resource_metadata
|
||||
),
|
||||
CONF_SCOPE: oauth_config.scopes,
|
||||
}
|
||||
)
|
||||
return await self.async_step_credentials_choice()
|
||||
@@ -384,143 +326,6 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
return await self.async_step_auth()
|
||||
|
||||
|
||||
async def _async_fetch_any(
|
||||
hass: HomeAssistant,
|
||||
urls: Iterable[str],
|
||||
) -> httpx.Response:
|
||||
"""Fetch all URLs concurrently and return the first successful response."""
|
||||
|
||||
async def fetch(url: str) -> httpx.Response:
|
||||
_LOGGER.debug("Fetching URL %s", url)
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except httpx.TimeoutException as error:
|
||||
_LOGGER.debug("Timeout fetching URL %s: %s", url, error)
|
||||
raise TimeoutConnectError from error
|
||||
except httpx.HTTPStatusError as error:
|
||||
_LOGGER.debug("Server error for URL %s: %s", url, error)
|
||||
if error.response.status_code == 404:
|
||||
raise NotFoundError from error
|
||||
raise CannotConnect from error
|
||||
except httpx.HTTPError as error:
|
||||
_LOGGER.debug("Cannot fetch URL %s: %s", url, error)
|
||||
raise CannotConnect from error
|
||||
|
||||
tasks = [asyncio.create_task(fetch(url)) for url in urls]
|
||||
return_err: Exception | None = None
|
||||
try:
|
||||
for future in asyncio.as_completed(tasks):
|
||||
try:
|
||||
return await future
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.debug("Fetch failed: %s", err)
|
||||
if return_err is None:
|
||||
return_err = err
|
||||
continue
|
||||
finally:
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
|
||||
raise return_err or CannotConnect("No responses received from any URL")
|
||||
|
||||
|
||||
async def async_discover_protected_resource(
|
||||
hass: HomeAssistant,
|
||||
auth_url: str,
|
||||
mcp_server_url: str,
|
||||
) -> ResourceMetadata:
|
||||
"""Discover the OAuth configuration for a protected resource for MCP spec version 2025-11-25+.
|
||||
|
||||
This implements the functionality in the MCP spec for discovery. We use the information
|
||||
from the WWW-Authenticate header to fetch the resource metadata implementing
|
||||
RFC9728.
|
||||
|
||||
For the url https://example.com/public/mcp we attempt these urls:
|
||||
- https://example.com/.well-known/oauth-protected-resource/public/mcp
|
||||
- https://example.com/.well-known/oauth-protected-resource
|
||||
"""
|
||||
parsed_url = URL(mcp_server_url)
|
||||
urls_to_try = {
|
||||
auth_url,
|
||||
str(
|
||||
parsed_url.with_path(
|
||||
f"{OAUTH_PROTECTED_RESOURCE_ENDPOINT}{parsed_url.path}"
|
||||
)
|
||||
),
|
||||
str(parsed_url.with_path(OAUTH_PROTECTED_RESOURCE_ENDPOINT)),
|
||||
}
|
||||
|
||||
response = await _async_fetch_any(hass, list(urls_to_try))
|
||||
|
||||
# Parse the OAuth Authorization Protected Resource Metadata (rfc9728). We
|
||||
# expect to find at least one authorization server in the response and
|
||||
# a valid resource field that matches the MCP server URL.
|
||||
data = response.json()
|
||||
if (
|
||||
not (authorization_servers := data.get("authorization_servers"))
|
||||
or not (resource := data.get("resource"))
|
||||
or (resource != mcp_server_url)
|
||||
):
|
||||
_LOGGER.error("Invalid OAuth resource metadata: %s", data)
|
||||
raise CannotConnect("OAuth resource metadata is invalid")
|
||||
return ResourceMetadata(
|
||||
authorization_servers=authorization_servers,
|
||||
supported_scopes=data.get("scopes_supported"),
|
||||
)
|
||||
|
||||
|
||||
def _authorization_server_discovery_paths(auth_server_url: URL) -> list[str]:
|
||||
"""Return the list of paths to try for OAuth server discovery.
|
||||
|
||||
For an auth server url with path components, e.g., https://auth.example.com/tenant1
|
||||
clients try endpoints in the following priority order:
|
||||
- OAuth 2.0 Authorization Server Metadata with path insertion:
|
||||
https://auth.example.com/.well-known/oauth-authorization-server/tenant1
|
||||
- OpenID Connect Discovery 1.0 with path insertion:
|
||||
https://auth.example.com/.well-known/openid-configuration/tenant1
|
||||
- OpenID Connect Discovery 1.0 path appending:
|
||||
https://auth.example.com/tenant1/.well-known/openid-configuration
|
||||
|
||||
For an auth server url without path components, e.g., https://auth.example.com
|
||||
clients try:
|
||||
- OAuth 2.0 Authorization Server Metadata:
|
||||
https://auth.example.com/.well-known/oauth-authorization-server
|
||||
- OpenID Connect Discovery 1.0:
|
||||
https://auth.example.com/.well-known/openid-configuration
|
||||
"""
|
||||
if auth_server_url.path and auth_server_url.path != "/":
|
||||
return [
|
||||
f"/.well-known/oauth-authorization-server{auth_server_url.path}",
|
||||
f"/.well-known/openid-configuration{auth_server_url.path}",
|
||||
f"{auth_server_url.path}/.well-known/openid-configuration",
|
||||
]
|
||||
return [
|
||||
"/.well-known/oauth-authorization-server",
|
||||
"/.well-known/openid-configuration",
|
||||
]
|
||||
|
||||
|
||||
def _select_scopes(
|
||||
auth_header: AuthenticateHeader | None,
|
||||
oauth_config: OAuthConfig,
|
||||
resource_metadata: ResourceMetadata | None,
|
||||
) -> list[str] | None:
|
||||
"""Select OAuth scopes based on the MCP spec scope selection strategy.
|
||||
|
||||
This follows the MCP spec strategy of preferring first the authenticate header,
|
||||
then the protected resource metadata, then finally the default scopes from
|
||||
the OAuth discovery.
|
||||
"""
|
||||
if auth_header and auth_header.scopes:
|
||||
return auth_header.scopes
|
||||
if resource_metadata and resource_metadata.supported_scopes:
|
||||
return resource_metadata.supported_scopes
|
||||
return oauth_config.scopes
|
||||
|
||||
|
||||
class InvalidUrl(HomeAssistantError):
|
||||
"""Error to indicate the URL format is invalid."""
|
||||
|
||||
@@ -533,18 +338,9 @@ class TimeoutConnectError(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class NotFoundError(CannotConnect):
|
||||
"""Error to indicate the resource was not found."""
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
def __init__(self, metadata: AuthenticateHeader | None = None) -> None:
|
||||
"""Initialize the error."""
|
||||
super().__init__()
|
||||
self.metadata = metadata
|
||||
|
||||
|
||||
class MissingCapabilities(HomeAssistantError):
|
||||
"""Error to indicate that the MCP server is missing required capabilities."""
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Diagnostics support for Met.no integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import MetWeatherConfigEntry
|
||||
|
||||
TO_REDACT = [
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
]
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: MetWeatherConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator_data = entry.runtime_data.data
|
||||
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": {
|
||||
"current_weather_data": coordinator_data.current_weather_data,
|
||||
"daily_forecast": coordinator_data.daily_forecast,
|
||||
"hourly_forecast": coordinator_data.hourly_forecast,
|
||||
},
|
||||
}
|
||||
@@ -213,8 +213,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if info := await self._async_validate_credentials(
|
||||
self._pending_host,
|
||||
errors,
|
||||
username=user_input.get(CONF_USERNAME),
|
||||
password=user_input.get(CONF_PASSWORD),
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
):
|
||||
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -222,8 +222,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title=info["title"],
|
||||
data={
|
||||
CONF_HOST: self._pending_host,
|
||||
CONF_USERNAME: user_input.get(CONF_USERNAME),
|
||||
CONF_PASSWORD: user_input.get(CONF_PASSWORD),
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -253,8 +253,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if info := await self._async_validate_credentials(
|
||||
reauth_entry.data[CONF_HOST],
|
||||
errors,
|
||||
username=user_input.get(CONF_USERNAME),
|
||||
password=user_input.get(CONF_PASSWORD),
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
):
|
||||
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
@@ -318,13 +318,11 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
if user_input is not None:
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
if info := await self._async_validate_credentials(
|
||||
self._pending_host,
|
||||
errors,
|
||||
username=username,
|
||||
password=password,
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
):
|
||||
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
@@ -332,8 +330,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
reconfigure_entry,
|
||||
data_updates={
|
||||
CONF_HOST: self._pending_host,
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nrgkick",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["nrgkick-api==1.7.1"],
|
||||
"zeroconf": ["_nrgkick._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiontfy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiontfy==0.8.1"]
|
||||
"requirements": ["aiontfy==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ from .const import (
|
||||
CONF_TOP_P,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_STT_NAME,
|
||||
DEFAULT_TTS_NAME,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
@@ -58,7 +57,6 @@ from .const import (
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_STT_OPTIONS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_P,
|
||||
RECOMMENDED_TTS_OPTIONS,
|
||||
@@ -68,7 +66,7 @@ from .entity import async_prepare_files_for_prompt
|
||||
SERVICE_GENERATE_IMAGE = "generate_image"
|
||||
SERVICE_GENERATE_CONTENT = "generate_content"
|
||||
|
||||
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION, Platform.STT, Platform.TTS)
|
||||
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION, Platform.TTS)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient]
|
||||
@@ -482,10 +480,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) ->
|
||||
_add_tts_subentry(hass, entry)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=5)
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 5:
|
||||
_add_stt_subentry(hass, entry)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=6)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
@@ -506,19 +500,6 @@ def _add_ai_task_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None
|
||||
)
|
||||
|
||||
|
||||
def _add_stt_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None:
|
||||
"""Add STT subentry to the config entry."""
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(RECOMMENDED_STT_OPTIONS),
|
||||
subentry_type="stt",
|
||||
title=DEFAULT_STT_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _add_tts_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None:
|
||||
"""Add TTS subentry to the config entry."""
|
||||
hass.config_entries.async_add_subentry(
|
||||
|
||||
@@ -68,8 +68,6 @@ from .const import (
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DEFAULT_STT_NAME,
|
||||
DEFAULT_STT_PROMPT,
|
||||
DEFAULT_TTS_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
@@ -80,8 +78,6 @@ from .const import (
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_REASONING_SUMMARY,
|
||||
RECOMMENDED_STT_MODEL,
|
||||
RECOMMENDED_STT_OPTIONS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_P,
|
||||
RECOMMENDED_TTS_OPTIONS,
|
||||
@@ -114,14 +110,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
client = openai.AsyncOpenAI(
|
||||
api_key=data[CONF_API_KEY], http_client=get_async_client(hass)
|
||||
)
|
||||
await client.models.list(timeout=10.0)
|
||||
await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list)
|
||||
|
||||
|
||||
class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for OpenAI Conversation."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 6
|
||||
MINOR_VERSION = 5
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -162,12 +158,6 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"subentry_type": "stt",
|
||||
"data": RECOMMENDED_STT_OPTIONS,
|
||||
"title": DEFAULT_STT_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"subentry_type": "tts",
|
||||
"data": RECOMMENDED_TTS_OPTIONS,
|
||||
@@ -214,7 +204,6 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return {
|
||||
"conversation": OpenAISubentryFlowHandler,
|
||||
"ai_task_data": OpenAISubentryFlowHandler,
|
||||
"stt": OpenAISubentrySTTFlowHandler,
|
||||
"tts": OpenAISubentryTTSFlowHandler,
|
||||
}
|
||||
|
||||
@@ -606,95 +595,6 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
return location_data
|
||||
|
||||
|
||||
class OpenAISubentrySTTFlowHandler(ConfigSubentryFlow):
|
||||
"""Flow for managing OpenAI STT subentries."""
|
||||
|
||||
options: dict[str, Any]
|
||||
|
||||
@property
|
||||
def _is_new(self) -> bool:
|
||||
"""Return if this is a new subentry."""
|
||||
return self.source == "user"
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Add a subentry."""
|
||||
self.options = RECOMMENDED_STT_OPTIONS.copy()
|
||||
return await self.async_step_init()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle reconfiguration of a subentry."""
|
||||
self.options = self._get_reconfigure_subentry().data.copy()
|
||||
return await self.async_step_init()
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Manage initial options."""
|
||||
# abort if entry is not loaded
|
||||
if self._get_entry().state != ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
|
||||
options = self.options
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
step_schema: VolDictType = {}
|
||||
|
||||
if self._is_new:
|
||||
step_schema[vol.Required(CONF_NAME, default=DEFAULT_STT_NAME)] = str
|
||||
|
||||
step_schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PROMPT,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_PROMPT, DEFAULT_STT_PROMPT)
|
||||
},
|
||||
): TextSelector(
|
||||
TextSelectorConfig(multiline=True, type=TextSelectorType.TEXT)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_CHAT_MODEL, default=RECOMMENDED_STT_MODEL
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
"gpt-4o-transcribe",
|
||||
"gpt-4o-mini-transcribe",
|
||||
"whisper-1",
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
custom_value=True,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
options.update(user_input)
|
||||
if not errors:
|
||||
if self._is_new:
|
||||
return self.async_create_entry(
|
||||
title=options.pop(CONF_NAME),
|
||||
data=options,
|
||||
)
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
self._get_reconfigure_subentry(),
|
||||
data=options,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(step_schema), options
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class OpenAISubentryTTSFlowHandler(ConfigSubentryFlow):
|
||||
"""Flow for managing OpenAI TTS subentries."""
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Constants for the OpenAI Conversation integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
from homeassistant.helpers import llm
|
||||
@@ -11,7 +10,6 @@ LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
|
||||
DEFAULT_CONVERSATION_NAME = "OpenAI Conversation"
|
||||
DEFAULT_AI_TASK_NAME = "OpenAI AI Task"
|
||||
DEFAULT_STT_NAME = "OpenAI STT"
|
||||
DEFAULT_TTS_NAME = "OpenAI TTS"
|
||||
DEFAULT_NAME = "OpenAI Conversation"
|
||||
|
||||
@@ -42,7 +40,6 @@ RECOMMENDED_IMAGE_MODEL = "gpt-image-1.5"
|
||||
RECOMMENDED_MAX_TOKENS = 3000
|
||||
RECOMMENDED_REASONING_EFFORT = "low"
|
||||
RECOMMENDED_REASONING_SUMMARY = "auto"
|
||||
RECOMMENDED_STT_MODEL = "gpt-4o-mini-transcribe"
|
||||
RECOMMENDED_TEMPERATURE = 1.0
|
||||
RECOMMENDED_TOP_P = 1.0
|
||||
RECOMMENDED_TTS_SPEED = 1.0
|
||||
@@ -51,9 +48,6 @@ RECOMMENDED_WEB_SEARCH = False
|
||||
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE = "medium"
|
||||
RECOMMENDED_WEB_SEARCH_USER_LOCATION = False
|
||||
RECOMMENDED_WEB_SEARCH_INLINE_CITATIONS = False
|
||||
DEFAULT_STT_PROMPT = (
|
||||
"The following conversation is a smart home user talking to Home Assistant."
|
||||
)
|
||||
|
||||
UNSUPPORTED_MODELS: list[str] = [
|
||||
"o1-mini",
|
||||
@@ -114,7 +108,6 @@ RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
RECOMMENDED_AI_TASK_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
RECOMMENDED_STT_OPTIONS: dict[str, Any] = {}
|
||||
RECOMMENDED_TTS_OPTIONS = {
|
||||
CONF_PROMPT: "",
|
||||
CONF_CHAT_MODEL: "gpt-4o-mini-tts",
|
||||
|
||||
@@ -92,7 +92,6 @@ from .const import (
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_REASONING_SUMMARY,
|
||||
RECOMMENDED_STT_MODEL,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_P,
|
||||
RECOMMENDED_VERBOSITY,
|
||||
@@ -472,12 +471,7 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="OpenAI",
|
||||
model=subentry.data.get(
|
||||
CONF_CHAT_MODEL,
|
||||
RECOMMENDED_CHAT_MODEL
|
||||
if subentry.subentry_type != "stt"
|
||||
else RECOMMENDED_STT_MODEL,
|
||||
),
|
||||
model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
|
||||
@@ -146,30 +146,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"stt": {
|
||||
"abort": {
|
||||
"entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"entry_type": "Speech-to-text",
|
||||
"initiate_flow": {
|
||||
"reconfigure": "Reconfigure speech-to-text service",
|
||||
"user": "Add speech-to-text service"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"chat_model": "Model",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"prompt": "[%key:common::config_flow::data::prompt%]"
|
||||
},
|
||||
"data_description": {
|
||||
"chat_model": "The model to use to transcribe speech.",
|
||||
"prompt": "Use this prompt to improve the quality of the transcripts. Translate to the pipeline language for best results. See the documentation for more details."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tts": {
|
||||
"abort": {
|
||||
"entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
"""Speech to text support for OpenAI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
import io
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
import wave
|
||||
|
||||
from openai import OpenAIError
|
||||
|
||||
from homeassistant.components import stt
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_PROMPT,
|
||||
DEFAULT_STT_PROMPT,
|
||||
RECOMMENDED_STT_MODEL,
|
||||
)
|
||||
from .entity import OpenAIBaseLLMEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import OpenAIConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: OpenAIConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up STT entities."""
|
||||
for subentry in config_entry.subentries.values():
|
||||
if subentry.subentry_type != "stt":
|
||||
continue
|
||||
|
||||
async_add_entities(
|
||||
[OpenAISTTEntity(config_entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class OpenAISTTEntity(stt.SpeechToTextEntity, OpenAIBaseLLMEntity):
|
||||
"""OpenAI Speech to text entity."""
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
"""Return a list of supported languages."""
|
||||
# https://developers.openai.com/api/docs/guides/speech-to-text#supported-languages
|
||||
# The model may also transcribe the audio in other languages but with lower quality
|
||||
return [
|
||||
"af-ZA", # Afrikaans
|
||||
"ar-SA", # Arabic
|
||||
"hy-AM", # Armenian
|
||||
"az-AZ", # Azerbaijani
|
||||
"be-BY", # Belarusian
|
||||
"bs-BA", # Bosnian
|
||||
"bg-BG", # Bulgarian
|
||||
"ca-ES", # Catalan
|
||||
"zh-CN", # Chinese (Mandarin)
|
||||
"hr-HR", # Croatian
|
||||
"cs-CZ", # Czech
|
||||
"da-DK", # Danish
|
||||
"nl-NL", # Dutch
|
||||
"en-US", # English
|
||||
"et-EE", # Estonian
|
||||
"fi-FI", # Finnish
|
||||
"fr-FR", # French
|
||||
"gl-ES", # Galician
|
||||
"de-DE", # German
|
||||
"el-GR", # Greek
|
||||
"he-IL", # Hebrew
|
||||
"hi-IN", # Hindi
|
||||
"hu-HU", # Hungarian
|
||||
"is-IS", # Icelandic
|
||||
"id-ID", # Indonesian
|
||||
"it-IT", # Italian
|
||||
"ja-JP", # Japanese
|
||||
"kn-IN", # Kannada
|
||||
"kk-KZ", # Kazakh
|
||||
"ko-KR", # Korean
|
||||
"lv-LV", # Latvian
|
||||
"lt-LT", # Lithuanian
|
||||
"mk-MK", # Macedonian
|
||||
"ms-MY", # Malay
|
||||
"mr-IN", # Marathi
|
||||
"mi-NZ", # Maori
|
||||
"ne-NP", # Nepali
|
||||
"no-NO", # Norwegian
|
||||
"fa-IR", # Persian
|
||||
"pl-PL", # Polish
|
||||
"pt-PT", # Portuguese
|
||||
"ro-RO", # Romanian
|
||||
"ru-RU", # Russian
|
||||
"sr-RS", # Serbian
|
||||
"sk-SK", # Slovak
|
||||
"sl-SI", # Slovenian
|
||||
"es-ES", # Spanish
|
||||
"sw-KE", # Swahili
|
||||
"sv-SE", # Swedish
|
||||
"fil-PH", # Tagalog (Filipino)
|
||||
"ta-IN", # Tamil
|
||||
"th-TH", # Thai
|
||||
"tr-TR", # Turkish
|
||||
"uk-UA", # Ukrainian
|
||||
"ur-PK", # Urdu
|
||||
"vi-VN", # Vietnamese
|
||||
"cy-GB", # Welsh
|
||||
]
|
||||
|
||||
@property
|
||||
def supported_formats(self) -> list[stt.AudioFormats]:
|
||||
"""Return a list of supported formats."""
|
||||
# https://developers.openai.com/api/docs/guides/speech-to-text#transcriptions
|
||||
return [stt.AudioFormats.WAV, stt.AudioFormats.OGG]
|
||||
|
||||
@property
|
||||
def supported_codecs(self) -> list[stt.AudioCodecs]:
|
||||
"""Return a list of supported codecs."""
|
||||
return [stt.AudioCodecs.PCM, stt.AudioCodecs.OPUS]
|
||||
|
||||
@property
|
||||
def supported_bit_rates(self) -> list[stt.AudioBitRates]:
|
||||
"""Return a list of supported bit rates."""
|
||||
return [
|
||||
stt.AudioBitRates.BITRATE_8,
|
||||
stt.AudioBitRates.BITRATE_16,
|
||||
stt.AudioBitRates.BITRATE_24,
|
||||
stt.AudioBitRates.BITRATE_32,
|
||||
]
|
||||
|
||||
@property
|
||||
def supported_sample_rates(self) -> list[stt.AudioSampleRates]:
|
||||
"""Return a list of supported sample rates."""
|
||||
return [
|
||||
stt.AudioSampleRates.SAMPLERATE_8000,
|
||||
stt.AudioSampleRates.SAMPLERATE_11000,
|
||||
stt.AudioSampleRates.SAMPLERATE_16000,
|
||||
stt.AudioSampleRates.SAMPLERATE_18900,
|
||||
stt.AudioSampleRates.SAMPLERATE_22000,
|
||||
stt.AudioSampleRates.SAMPLERATE_32000,
|
||||
stt.AudioSampleRates.SAMPLERATE_37800,
|
||||
stt.AudioSampleRates.SAMPLERATE_44100,
|
||||
stt.AudioSampleRates.SAMPLERATE_48000,
|
||||
]
|
||||
|
||||
@property
|
||||
def supported_channels(self) -> list[stt.AudioChannels]:
|
||||
"""Return a list of supported channels."""
|
||||
return [stt.AudioChannels.CHANNEL_MONO, stt.AudioChannels.CHANNEL_STEREO]
|
||||
|
||||
async def async_process_audio_stream(
|
||||
self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes]
|
||||
) -> stt.SpeechResult:
|
||||
"""Process an audio stream to STT service."""
|
||||
audio_bytes = bytearray()
|
||||
async for chunk in stream:
|
||||
audio_bytes.extend(chunk)
|
||||
audio_data = bytes(audio_bytes)
|
||||
if metadata.format == stt.AudioFormats.WAV:
|
||||
# Add missing wav header
|
||||
wav_buffer = io.BytesIO()
|
||||
|
||||
with wave.open(wav_buffer, "wb") as wf:
|
||||
wf.setnchannels(metadata.channel.value)
|
||||
wf.setsampwidth(metadata.bit_rate.value // 8)
|
||||
wf.setframerate(metadata.sample_rate.value)
|
||||
wf.writeframes(audio_data)
|
||||
|
||||
audio_data = wav_buffer.getvalue()
|
||||
|
||||
options = self.subentry.data
|
||||
client = self.entry.runtime_data
|
||||
|
||||
try:
|
||||
response = await client.audio.transcriptions.create(
|
||||
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL),
|
||||
file=(f"a.{metadata.format.value}", audio_data),
|
||||
response_format="json",
|
||||
language=metadata.language.split("-")[0],
|
||||
prompt=options.get(CONF_PROMPT, DEFAULT_STT_PROMPT),
|
||||
)
|
||||
except OpenAIError:
|
||||
_LOGGER.exception("Error during STT")
|
||||
else:
|
||||
if response.text:
|
||||
return stt.SpeechResult(
|
||||
response.text,
|
||||
stt.SpeechResultState.SUCCESS,
|
||||
)
|
||||
|
||||
return stt.SpeechResult(None, stt.SpeechResultState.ERROR)
|
||||
@@ -15,14 +15,12 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PortainerConfigEntry
|
||||
from .const import CONTAINER_STATE_RUNNING, STACK_STATUS_ACTIVE
|
||||
from .const import CONTAINER_STATE_RUNNING
|
||||
from .coordinator import PortainerContainerData, PortainerCoordinator
|
||||
from .entity import (
|
||||
PortainerContainerEntity,
|
||||
PortainerCoordinatorData,
|
||||
PortainerEndpointEntity,
|
||||
PortainerStackData,
|
||||
PortainerStackEntity,
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
@@ -42,13 +40,6 @@ class PortainerEndpointBinarySensorEntityDescription(BinarySensorEntityDescripti
|
||||
state_fn: Callable[[PortainerCoordinatorData], bool | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PortainerStackBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Class to hold Portainer stack binary sensor description."""
|
||||
|
||||
state_fn: Callable[[PortainerStackData], bool | None]
|
||||
|
||||
|
||||
CONTAINER_SENSORS: tuple[PortainerContainerBinarySensorEntityDescription, ...] = (
|
||||
PortainerContainerBinarySensorEntityDescription(
|
||||
key="status",
|
||||
@@ -69,18 +60,6 @@ ENDPOINT_SENSORS: tuple[PortainerEndpointBinarySensorEntityDescription, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
STACK_SENSORS: tuple[PortainerStackBinarySensorEntityDescription, ...] = (
|
||||
PortainerStackBinarySensorEntityDescription(
|
||||
key="stack_status",
|
||||
translation_key="status",
|
||||
state_fn=lambda data: (
|
||||
data.stack.status == STACK_STATUS_ACTIVE
|
||||
), # 1 = Active | 2 = Inactive
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -119,24 +98,9 @@ async def async_setup_entry(
|
||||
if entity_description.state_fn(container)
|
||||
)
|
||||
|
||||
def _async_add_new_stacks(
|
||||
stacks: list[tuple[PortainerCoordinatorData, PortainerStackData]],
|
||||
) -> None:
|
||||
"""Add new stack sensors."""
|
||||
async_add_entities(
|
||||
PortainerStackSensor(
|
||||
coordinator,
|
||||
entity_description,
|
||||
stack,
|
||||
endpoint,
|
||||
)
|
||||
for (endpoint, stack) in stacks
|
||||
for entity_description in STACK_SENSORS
|
||||
)
|
||||
|
||||
coordinator.new_endpoints_callbacks.append(_async_add_new_endpoints)
|
||||
coordinator.new_containers_callbacks.append(_async_add_new_containers)
|
||||
coordinator.new_stacks_callbacks.append(_async_add_new_stacks)
|
||||
|
||||
_async_add_new_endpoints(
|
||||
[
|
||||
endpoint
|
||||
@@ -151,13 +115,6 @@ async def async_setup_entry(
|
||||
for container in endpoint.containers.values()
|
||||
]
|
||||
)
|
||||
_async_add_new_stacks(
|
||||
[
|
||||
(endpoint, stack)
|
||||
for endpoint in coordinator.data.values()
|
||||
for stack in endpoint.stacks.values()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class PortainerEndpointSensor(PortainerEndpointEntity, BinarySensorEntity):
|
||||
@@ -205,27 +162,3 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.state_fn(self.container_data)
|
||||
|
||||
|
||||
class PortainerStackSensor(PortainerStackEntity, BinarySensorEntity):
|
||||
"""Representation of a Portainer stack sensor."""
|
||||
|
||||
entity_description: PortainerStackBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerStackBinarySensorEntityDescription,
|
||||
device_info: PortainerStackData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer stack sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.stack.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.state_fn(self.stack_data)
|
||||
|
||||
@@ -7,11 +7,3 @@ DEFAULT_NAME = "Portainer"
|
||||
ENDPOINT_STATUS_DOWN = 2
|
||||
|
||||
CONTAINER_STATE_RUNNING = "running"
|
||||
|
||||
STACK_STATUS_ACTIVE = 1
|
||||
STACK_STATUS_INACTIVE = 2
|
||||
|
||||
|
||||
STACK_TYPE_SWARM = 1
|
||||
STACK_TYPE_COMPOSE = 2
|
||||
STACK_TYPE_KUBERNETES = 3
|
||||
|
||||
@@ -21,7 +21,6 @@ from pyportainer.models.docker import (
|
||||
)
|
||||
from pyportainer.models.docker_inspect import DockerInfo, DockerVersion
|
||||
from pyportainer.models.portainer import Endpoint
|
||||
from pyportainer.models.stacks import Stack
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_URL
|
||||
@@ -49,7 +48,6 @@ class PortainerCoordinatorData:
|
||||
docker_version: DockerVersion
|
||||
docker_info: DockerInfo
|
||||
docker_system_df: DockerSystemDF
|
||||
stacks: dict[str, PortainerStackData]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -59,15 +57,6 @@ class PortainerContainerData:
|
||||
container: DockerContainer
|
||||
stats: DockerContainerStats | None
|
||||
stats_pre: DockerContainerStats | None
|
||||
stack: Stack | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PortainerStackData:
|
||||
"""Stack data held by the Portainer coordinator."""
|
||||
|
||||
stack: Stack
|
||||
container_count: int = 0
|
||||
|
||||
|
||||
class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorData]]):
|
||||
@@ -93,7 +82,6 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
|
||||
|
||||
self.known_endpoints: set[int] = set()
|
||||
self.known_containers: set[tuple[int, str]] = set()
|
||||
self.known_stacks: set[tuple[int, str]] = set()
|
||||
|
||||
self.new_endpoints_callbacks: list[
|
||||
Callable[[list[PortainerCoordinatorData]], None]
|
||||
@@ -103,9 +91,6 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
|
||||
[list[tuple[PortainerCoordinatorData, PortainerContainerData]]], None
|
||||
]
|
||||
] = []
|
||||
self.new_stacks_callbacks: list[
|
||||
Callable[[list[tuple[PortainerCoordinatorData, PortainerStackData]]], None]
|
||||
] = []
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the Portainer Data Update Coordinator."""
|
||||
@@ -168,47 +153,28 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
|
||||
docker_version,
|
||||
docker_info,
|
||||
docker_system_df,
|
||||
stacks,
|
||||
) = await asyncio.gather(
|
||||
self.portainer.get_containers(endpoint_id=endpoint.id),
|
||||
self.portainer.docker_version(endpoint_id=endpoint.id),
|
||||
self.portainer.docker_info(endpoint_id=endpoint.id),
|
||||
self.portainer.get_containers(endpoint.id),
|
||||
self.portainer.docker_version(endpoint.id),
|
||||
self.portainer.docker_info(endpoint.id),
|
||||
self.portainer.docker_system_df(endpoint.id),
|
||||
self.portainer.get_stacks(endpoint_id=endpoint.id),
|
||||
)
|
||||
|
||||
prev_endpoint = self.data.get(endpoint.id) if self.data else None
|
||||
container_map: dict[str, PortainerContainerData] = {}
|
||||
stack_map: dict[str, PortainerStackData] = {
|
||||
stack.name: PortainerStackData(stack=stack, container_count=0)
|
||||
for stack in stacks
|
||||
}
|
||||
|
||||
# Map containers, started and stopped
|
||||
for container in containers:
|
||||
container_name = self._get_container_name(container.names[0])
|
||||
prev_container = (
|
||||
prev_endpoint.containers.get(container_name)
|
||||
prev_endpoint.containers[container_name]
|
||||
if prev_endpoint
|
||||
else None
|
||||
)
|
||||
|
||||
# Check if container belongs to a stack via docker compose label
|
||||
stack_name: str | None = (
|
||||
container.labels.get("com.docker.compose.project")
|
||||
if container.labels
|
||||
else None
|
||||
)
|
||||
if stack_name and (stack_data := stack_map.get(stack_name)):
|
||||
stack_data.container_count += 1
|
||||
|
||||
container_map[container_name] = PortainerContainerData(
|
||||
container=container,
|
||||
stats=None,
|
||||
stats_pre=prev_container.stats if prev_container else None,
|
||||
stack=stack_map[stack_name].stack
|
||||
if stack_name and stack_name in stack_map
|
||||
else None,
|
||||
)
|
||||
|
||||
# Separately fetch stats for running containers
|
||||
@@ -263,7 +229,6 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
|
||||
docker_version=docker_version,
|
||||
docker_info=docker_info,
|
||||
docker_system_df=docker_system_df,
|
||||
stacks=stack_map,
|
||||
)
|
||||
|
||||
self._async_add_remove_endpoints(mapped_endpoints)
|
||||
@@ -291,17 +256,6 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
|
||||
_LOGGER.debug("New containers found: %s", new_containers)
|
||||
self.known_containers.update(new_containers)
|
||||
|
||||
# Stack management
|
||||
current_stacks = {
|
||||
(endpoint.id, stack_name)
|
||||
for endpoint in mapped_endpoints.values()
|
||||
for stack_name in endpoint.stacks
|
||||
}
|
||||
new_stacks = current_stacks - self.known_stacks
|
||||
if new_stacks:
|
||||
_LOGGER.debug("New stacks found: %s", new_stacks)
|
||||
self.known_stacks.update(new_stacks)
|
||||
|
||||
def _get_container_name(self, container_name: str) -> str:
|
||||
"""Sanitize to get a proper container name."""
|
||||
return container_name.replace("/", " ").strip()
|
||||
|
||||
@@ -11,7 +11,6 @@ from .coordinator import (
|
||||
PortainerContainerData,
|
||||
PortainerCoordinator,
|
||||
PortainerCoordinatorData,
|
||||
PortainerStackData,
|
||||
)
|
||||
|
||||
|
||||
@@ -87,13 +86,9 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
|
||||
),
|
||||
model="Container",
|
||||
name=self.device_name,
|
||||
# If the container belongs to a stack, nest it under the stack
|
||||
# else it's the endpoint
|
||||
via_device=(
|
||||
DOMAIN,
|
||||
f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_{device_info.stack.name}"
|
||||
if device_info.stack
|
||||
else f"{coordinator.config_entry.entry_id}_{self.endpoint_id}",
|
||||
f"{self.coordinator.config_entry.entry_id}_{self.endpoint_id}",
|
||||
),
|
||||
translation_key=None if self.device_name else "unknown_container",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
@@ -112,54 +107,3 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
|
||||
def container_data(self) -> PortainerContainerData:
|
||||
"""Return the coordinator data for this container."""
|
||||
return self.coordinator.data[self.endpoint_id].containers[self.device_name]
|
||||
|
||||
|
||||
class PortainerStackEntity(PortainerCoordinatorEntity):
|
||||
"""Base implementation for Portainer stack."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_info: PortainerStackData,
|
||||
coordinator: PortainerCoordinator,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize a Portainer stack."""
|
||||
super().__init__(coordinator)
|
||||
self._device_info = device_info
|
||||
self.stack_id = device_info.stack.id
|
||||
self.device_name = device_info.stack.name
|
||||
self.endpoint_id = via_device.endpoint.id
|
||||
self.endpoint_name = via_device.endpoint.name
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(
|
||||
DOMAIN,
|
||||
f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_{self.device_name}",
|
||||
)
|
||||
},
|
||||
manufacturer=DEFAULT_NAME,
|
||||
configuration_url=URL(
|
||||
f"{coordinator.config_entry.data[CONF_URL]}#!/{self.endpoint_id}/docker/stacks/{self.device_name}"
|
||||
),
|
||||
model="Stack",
|
||||
name=self.device_name,
|
||||
via_device=(
|
||||
DOMAIN,
|
||||
f"{coordinator.config_entry.entry_id}_{self.endpoint_id}",
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the stack is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.endpoint_id in self.coordinator.data
|
||||
and self.device_name in self.coordinator.data[self.endpoint_id].stacks
|
||||
)
|
||||
|
||||
@property
|
||||
def stack_data(self) -> PortainerStackData:
|
||||
"""Return the coordinator data for this stack."""
|
||||
return self.coordinator.data[self.endpoint_id].stacks[self.device_name]
|
||||
|
||||
@@ -70,12 +70,6 @@
|
||||
"operating_system_version": {
|
||||
"default": "mdi:alpha-v-box"
|
||||
},
|
||||
"stack_containers_count": {
|
||||
"default": "mdi:server"
|
||||
},
|
||||
"stack_type": {
|
||||
"default": "mdi:server"
|
||||
},
|
||||
"volume_disk_usage_total_size": {
|
||||
"default": "mdi:harddisk"
|
||||
}
|
||||
@@ -86,12 +80,6 @@
|
||||
"state": {
|
||||
"on": "mdi:arrow-up-box"
|
||||
}
|
||||
},
|
||||
"stack": {
|
||||
"default": "mdi:arrow-down-box",
|
||||
"state": {
|
||||
"on": "mdi:arrow-up-box"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,18 +17,15 @@ from homeassistant.const import PERCENTAGE, UnitOfInformation
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import STACK_TYPE_COMPOSE, STACK_TYPE_KUBERNETES, STACK_TYPE_SWARM
|
||||
from .coordinator import (
|
||||
PortainerConfigEntry,
|
||||
PortainerContainerData,
|
||||
PortainerCoordinator,
|
||||
PortainerStackData,
|
||||
)
|
||||
from .entity import (
|
||||
PortainerContainerEntity,
|
||||
PortainerCoordinatorData,
|
||||
PortainerEndpointEntity,
|
||||
PortainerStackEntity,
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
@@ -48,13 +45,6 @@ class PortainerEndpointSensorEntityDescription(SensorEntityDescription):
|
||||
value_fn: Callable[[PortainerCoordinatorData], StateType]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PortainerStackSensorEntityDescription(SensorEntityDescription):
|
||||
"""Class to hold Portainer stack sensor description."""
|
||||
|
||||
value_fn: Callable[[PortainerStackData], StateType]
|
||||
|
||||
|
||||
CONTAINER_SENSORS: tuple[PortainerContainerSensorEntityDescription, ...] = (
|
||||
PortainerContainerSensorEntityDescription(
|
||||
key="image",
|
||||
@@ -288,32 +278,6 @@ ENDPOINT_SENSORS: tuple[PortainerEndpointSensorEntityDescription, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
STACK_SENSORS: tuple[PortainerStackSensorEntityDescription, ...] = (
|
||||
PortainerStackSensorEntityDescription(
|
||||
key="stack_type",
|
||||
translation_key="stack_type",
|
||||
value_fn=lambda data: (
|
||||
"swarm"
|
||||
if data.stack.type == STACK_TYPE_SWARM
|
||||
else "compose"
|
||||
if data.stack.type == STACK_TYPE_COMPOSE
|
||||
else "kubernetes"
|
||||
if data.stack.type == STACK_TYPE_KUBERNETES
|
||||
else None
|
||||
),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["swarm", "compose", "kubernetes"],
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
PortainerStackSensorEntityDescription(
|
||||
key="stack_containers_count",
|
||||
translation_key="stack_containers_count",
|
||||
value_fn=lambda data: data.container_count,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -351,24 +315,8 @@ async def async_setup_entry(
|
||||
for entity_description in CONTAINER_SENSORS
|
||||
)
|
||||
|
||||
def _async_add_new_stacks(
|
||||
stacks: list[tuple[PortainerCoordinatorData, PortainerStackData]],
|
||||
) -> None:
|
||||
"""Add new stack sensors."""
|
||||
async_add_entities(
|
||||
PortainerStackSensor(
|
||||
coordinator,
|
||||
entity_description,
|
||||
stack,
|
||||
endpoint,
|
||||
)
|
||||
for (endpoint, stack) in stacks
|
||||
for entity_description in STACK_SENSORS
|
||||
)
|
||||
|
||||
coordinator.new_endpoints_callbacks.append(_async_add_new_endpoints)
|
||||
coordinator.new_containers_callbacks.append(_async_add_new_containers)
|
||||
coordinator.new_stacks_callbacks.append(_async_add_new_stacks)
|
||||
|
||||
_async_add_new_endpoints(
|
||||
[
|
||||
@@ -384,13 +332,6 @@ async def async_setup_entry(
|
||||
for container in endpoint.containers.values()
|
||||
]
|
||||
)
|
||||
_async_add_new_stacks(
|
||||
[
|
||||
(endpoint, stack)
|
||||
for endpoint in coordinator.data.values()
|
||||
for stack in endpoint.stacks.values()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class PortainerContainerSensor(PortainerContainerEntity, SensorEntity):
|
||||
@@ -439,27 +380,3 @@ class PortainerEndpointSensor(PortainerEndpointEntity, SensorEntity):
|
||||
"""Return the state of the sensor."""
|
||||
endpoint_data = self.coordinator.data[self._device_info.endpoint.id]
|
||||
return self.entity_description.value_fn(endpoint_data)
|
||||
|
||||
|
||||
class PortainerStackSensor(PortainerStackEntity, SensorEntity):
|
||||
"""Representation of a Portainer stack sensor."""
|
||||
|
||||
entity_description: PortainerStackSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerStackSensorEntityDescription,
|
||||
device_info: PortainerStackData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer stack sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.stack.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.stack_data)
|
||||
|
||||
@@ -147,18 +147,6 @@
|
||||
"operating_system_version": {
|
||||
"name": "Operating system version"
|
||||
},
|
||||
"stack_containers_count": {
|
||||
"name": "Containers",
|
||||
"unit_of_measurement": "containers"
|
||||
},
|
||||
"stack_type": {
|
||||
"name": "Type",
|
||||
"state": {
|
||||
"compose": "Compose",
|
||||
"kubernetes": "Kubernetes",
|
||||
"swarm": "Swarm"
|
||||
}
|
||||
},
|
||||
"volume_disk_usage_total_size": {
|
||||
"name": "Volume disk usage total size"
|
||||
}
|
||||
@@ -166,9 +154,6 @@
|
||||
"switch": {
|
||||
"container": {
|
||||
"name": "Container"
|
||||
},
|
||||
"stack": {
|
||||
"name": "Stack"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,17 +23,9 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PortainerConfigEntry
|
||||
from .const import DOMAIN, STACK_STATUS_ACTIVE
|
||||
from .coordinator import (
|
||||
PortainerContainerData,
|
||||
PortainerCoordinator,
|
||||
PortainerStackData,
|
||||
)
|
||||
from .entity import (
|
||||
PortainerContainerEntity,
|
||||
PortainerCoordinatorData,
|
||||
PortainerStackEntity,
|
||||
)
|
||||
from .const import DOMAIN
|
||||
from .coordinator import PortainerContainerData, PortainerCoordinator
|
||||
from .entity import PortainerContainerEntity, PortainerCoordinatorData
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -45,19 +37,10 @@ class PortainerSwitchEntityDescription(SwitchEntityDescription):
|
||||
turn_off_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PortainerStackSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Class to hold Portainer stack switch description."""
|
||||
|
||||
is_on_fn: Callable[[PortainerStackData], bool | None]
|
||||
turn_on_fn: Callable[[str, Portainer, int, int], Coroutine[Any, Any, None]]
|
||||
turn_off_fn: Callable[[str, Portainer, int, int], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def perform_container_action(
|
||||
async def perform_action(
|
||||
action: str, portainer: Portainer, endpoint_id: int, container_id: str
|
||||
) -> None:
|
||||
"""Perform an action on a container."""
|
||||
@@ -87,52 +70,14 @@ async def perform_container_action(
|
||||
) from err
|
||||
|
||||
|
||||
async def perform_stack_action(
|
||||
action: str, portainer: Portainer, endpoint_id: int, stack_id: int
|
||||
) -> None:
|
||||
"""Perform an action on a stack."""
|
||||
try:
|
||||
match action:
|
||||
case "start":
|
||||
await portainer.start_stack(stack_id, endpoint_id)
|
||||
case "stop":
|
||||
await portainer.stop_stack(stack_id, endpoint_id)
|
||||
except PortainerAuthenticationError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth_no_details",
|
||||
) from err
|
||||
except PortainerConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_no_details",
|
||||
) from err
|
||||
except PortainerTimeoutError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connect_no_details",
|
||||
) from err
|
||||
|
||||
|
||||
CONTAINER_SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = (
|
||||
SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = (
|
||||
PortainerSwitchEntityDescription(
|
||||
key="container",
|
||||
translation_key="container",
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
is_on_fn=lambda data: data.container.state == "running",
|
||||
turn_on_fn=perform_container_action,
|
||||
turn_off_fn=perform_container_action,
|
||||
),
|
||||
)
|
||||
|
||||
STACK_SWITCHES: tuple[PortainerStackSwitchEntityDescription, ...] = (
|
||||
PortainerStackSwitchEntityDescription(
|
||||
key="stack",
|
||||
translation_key="stack",
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
is_on_fn=lambda data: data.stack.status == STACK_STATUS_ACTIVE,
|
||||
turn_on_fn=perform_stack_action,
|
||||
turn_off_fn=perform_stack_action,
|
||||
turn_on_fn=perform_action,
|
||||
turn_off_fn=perform_action,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -157,26 +102,10 @@ async def async_setup_entry(
|
||||
endpoint,
|
||||
)
|
||||
for (endpoint, container) in containers
|
||||
for entity_description in CONTAINER_SWITCHES
|
||||
)
|
||||
|
||||
def _async_add_new_stacks(
|
||||
stacks: list[tuple[PortainerCoordinatorData, PortainerStackData]],
|
||||
) -> None:
|
||||
"""Add new stack switch sensors."""
|
||||
async_add_entities(
|
||||
PortainerStackSwitch(
|
||||
coordinator,
|
||||
entity_description,
|
||||
stack,
|
||||
endpoint,
|
||||
)
|
||||
for (endpoint, stack) in stacks
|
||||
for entity_description in STACK_SWITCHES
|
||||
for entity_description in SWITCHES
|
||||
)
|
||||
|
||||
coordinator.new_containers_callbacks.append(_async_add_new_containers)
|
||||
coordinator.new_stacks_callbacks.append(_async_add_new_stacks)
|
||||
_async_add_new_containers(
|
||||
[
|
||||
(endpoint, container)
|
||||
@@ -184,13 +113,6 @@ async def async_setup_entry(
|
||||
for container in endpoint.containers.values()
|
||||
]
|
||||
)
|
||||
_async_add_new_stacks(
|
||||
[
|
||||
(endpoint, stack)
|
||||
for endpoint in coordinator.data.values()
|
||||
for stack in endpoint.stacks.values()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity):
|
||||
@@ -235,47 +157,3 @@ class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity):
|
||||
self.container_data.container.id,
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class PortainerStackSwitch(PortainerStackEntity, SwitchEntity):
|
||||
"""Representation of a Portainer stack switch."""
|
||||
|
||||
entity_description: PortainerStackSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerStackSwitchEntityDescription,
|
||||
device_info: PortainerStackData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer stack switch."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.stack.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the state of the device."""
|
||||
return self.entity_description.is_on_fn(self.stack_data)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Start (turn on) the stack."""
|
||||
await self.entity_description.turn_on_fn(
|
||||
"start",
|
||||
self.coordinator.portainer,
|
||||
self.endpoint_id,
|
||||
self.stack_data.stack.id,
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Stop (turn off) the stack."""
|
||||
await self.entity_description.turn_off_fn(
|
||||
"stop",
|
||||
self.coordinator.portainer,
|
||||
self.endpoint_id,
|
||||
self.stack_data.stack.id,
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["powerfox==2.1.1"],
|
||||
"requirements": ["powerfox==2.1.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "powerfox*",
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError, PowerfoxLocal
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
SOURCE_USER,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
@@ -27,12 +21,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Powerfox Local."""
|
||||
@@ -45,7 +33,7 @@ class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the user step."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._host = user_input[CONF_HOST]
|
||||
@@ -59,15 +47,7 @@ class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except PowerfoxConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
if self.source == SOURCE_USER:
|
||||
return self._async_create_entry()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data={
|
||||
CONF_HOST: self._host,
|
||||
CONF_API_KEY: self._api_key,
|
||||
},
|
||||
)
|
||||
return self._async_create_entry()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
@@ -104,51 +84,6 @@ class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a confirmation flow for zeroconf discovery."""
|
||||
return self._async_create_entry()
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle re-authentication flow."""
|
||||
self._host = entry_data[CONF_HOST]
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle re-authentication confirmation."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._api_key = user_input[CONF_API_KEY]
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
client = PowerfoxLocal(
|
||||
host=reauth_entry.data[CONF_HOST],
|
||||
api_key=user_input[CONF_API_KEY],
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
try:
|
||||
await client.value()
|
||||
except PowerfoxAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except PowerfoxConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration."""
|
||||
return await self.async_step_user()
|
||||
|
||||
def _async_create_entry(self) -> ConfigFlowResult:
|
||||
"""Create a config entry."""
|
||||
return self.async_create_entry(
|
||||
@@ -168,8 +103,5 @@ class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
await client.value()
|
||||
|
||||
await self.async_set_unique_id(self._device_id, raise_on_progress=False)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
else:
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
|
||||
await self.async_set_unique_id(self._device_id)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
|
||||
|
||||
@@ -2,17 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from powerfox import (
|
||||
LocalResponse,
|
||||
PowerfoxAuthenticationError,
|
||||
PowerfoxConnectionError,
|
||||
PowerfoxLocal,
|
||||
)
|
||||
from powerfox import LocalResponse, PowerfoxConnectionError, PowerfoxLocal
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -46,12 +40,6 @@ class PowerfoxLocalDataUpdateCoordinator(DataUpdateCoordinator[LocalResponse]):
|
||||
"""Fetch data from the local poweropti."""
|
||||
try:
|
||||
return await self.client.value()
|
||||
except PowerfoxAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
except PowerfoxConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Support for Powerfox Local diagnostics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import PowerfoxLocalConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: PowerfoxLocalConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for Powerfox Local config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"power": coordinator.data.power,
|
||||
"energy_usage": coordinator.data.energy_usage,
|
||||
"energy_usage_high_tariff": coordinator.data.energy_usage_high_tariff,
|
||||
"energy_usage_low_tariff": coordinator.data.energy_usage_low_tariff,
|
||||
"energy_return": coordinator.data.energy_return,
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/powerfox_local",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["powerfox==2.1.1"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["powerfox==2.1.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "powerfox*",
|
||||
|
||||
@@ -43,12 +43,12 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
@@ -74,7 +74,7 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is no need for icon translations.
|
||||
reconfiguration-flow: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
||||
@@ -2,26 +2,13 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::powerfox_local::config::step::user::data_description::api_key%]"
|
||||
},
|
||||
"description": "The API key for your Poweropti device is no longer valid.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
@@ -56,9 +43,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_auth": {
|
||||
"message": "Error while authenticating with the device: {error}"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Error while updating the device: {error}"
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -74,20 +74,16 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
raise ProxmoxSSLError from err
|
||||
except ConnectTimeout as err:
|
||||
raise ProxmoxConnectTimeout from err
|
||||
except ResourceException as err:
|
||||
except (ResourceException, requests.exceptions.ConnectionError) as err:
|
||||
raise ProxmoxNoNodesFound from err
|
||||
except requests.exceptions.ConnectionError as err:
|
||||
raise ProxmoxConnectionError from err
|
||||
|
||||
nodes_data: list[dict[str, Any]] = []
|
||||
for node in nodes:
|
||||
try:
|
||||
vms = client.nodes(node["node"]).qemu.get()
|
||||
containers = client.nodes(node["node"]).lxc.get()
|
||||
except ResourceException as err:
|
||||
except (ResourceException, requests.exceptions.ConnectionError) as err:
|
||||
raise ProxmoxNoNodesFound from err
|
||||
except requests.exceptions.ConnectionError as err:
|
||||
raise ProxmoxConnectionError from err
|
||||
|
||||
nodes_data.append(
|
||||
{
|
||||
@@ -201,30 +197,18 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Validate the user input. Return nodes data and/or errors."""
|
||||
errors: dict[str, str] = {}
|
||||
proxmox_nodes: list[dict[str, Any]] = []
|
||||
err: ProxmoxError | None = None
|
||||
try:
|
||||
proxmox_nodes = await self.hass.async_add_executor_job(
|
||||
_get_nodes_data, user_input
|
||||
)
|
||||
except ProxmoxConnectTimeout as exc:
|
||||
except ProxmoxConnectTimeout:
|
||||
errors["base"] = "connect_timeout"
|
||||
err = exc
|
||||
except ProxmoxAuthenticationError as exc:
|
||||
except ProxmoxAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
err = exc
|
||||
except ProxmoxSSLError as exc:
|
||||
except ProxmoxSSLError:
|
||||
errors["base"] = "ssl_error"
|
||||
err = exc
|
||||
except ProxmoxNoNodesFound as exc:
|
||||
except ProxmoxNoNodesFound:
|
||||
errors["base"] = "no_nodes_found"
|
||||
err = exc
|
||||
except ProxmoxConnectionError as exc:
|
||||
errors["base"] = "cannot_connect"
|
||||
err = exc
|
||||
|
||||
if err is not None:
|
||||
_LOGGER.debug("Error: %s: %s", errors["base"], err)
|
||||
|
||||
return proxmox_nodes, errors
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
@@ -243,8 +227,6 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="ssl_error")
|
||||
except ProxmoxNoNodesFound:
|
||||
return self.async_abort(reason="no_nodes_found")
|
||||
except ProxmoxConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=import_data[CONF_HOST],
|
||||
@@ -252,25 +234,17 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
|
||||
class ProxmoxError(HomeAssistantError):
|
||||
"""Base class for Proxmox VE errors."""
|
||||
|
||||
|
||||
class ProxmoxNoNodesFound(ProxmoxError):
|
||||
class ProxmoxNoNodesFound(HomeAssistantError):
|
||||
"""Error to indicate no nodes found."""
|
||||
|
||||
|
||||
class ProxmoxConnectTimeout(ProxmoxError):
|
||||
class ProxmoxConnectTimeout(HomeAssistantError):
|
||||
"""Error to indicate a connection timeout."""
|
||||
|
||||
|
||||
class ProxmoxSSLError(ProxmoxError):
|
||||
class ProxmoxSSLError(HomeAssistantError):
|
||||
"""Error to indicate an SSL error."""
|
||||
|
||||
|
||||
class ProxmoxAuthenticationError(ProxmoxError):
|
||||
class ProxmoxAuthenticationError(HomeAssistantError):
|
||||
"""Error to indicate an authentication error."""
|
||||
|
||||
|
||||
class ProxmoxConnectionError(ProxmoxError):
|
||||
"""Error to indicate a connection error."""
|
||||
|
||||
@@ -101,18 +101,12 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
|
||||
translation_key="timeout_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except ResourceException as err:
|
||||
except (ResourceException, requests.exceptions.ConnectionError) as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_nodes_found",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except requests.exceptions.ConnectionError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
async def _async_update_data(self) -> dict[str, ProxmoxNodeData]:
|
||||
"""Fetch data from Proxmox VE API."""
|
||||
@@ -139,18 +133,12 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
|
||||
translation_key="timeout_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except ResourceException as err:
|
||||
except (ResourceException, requests.exceptions.ConnectionError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_nodes_found",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except requests.exceptions.ConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
data: dict[str, ProxmoxNodeData] = {}
|
||||
for node, (vms, containers) in zip(nodes, vms_containers, strict=True):
|
||||
|
||||
@@ -13,71 +13,6 @@
|
||||
"stop": {
|
||||
"default": "mdi:stop"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"container_cpu": {
|
||||
"default": "mdi:cpu-64-bit"
|
||||
},
|
||||
"container_disk": {
|
||||
"default": "mdi:harddisk"
|
||||
},
|
||||
"container_max_cpu": {
|
||||
"default": "mdi:cpu-64-bit"
|
||||
},
|
||||
"container_max_disk": {
|
||||
"default": "mdi:harddisk"
|
||||
},
|
||||
"container_max_memory": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"container_memory": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"container_status": {
|
||||
"default": "mdi:server"
|
||||
},
|
||||
"node_cpu": {
|
||||
"default": "mdi:cpu-64-bit"
|
||||
},
|
||||
"node_disk": {
|
||||
"default": "mdi:harddisk"
|
||||
},
|
||||
"node_max_cpu": {
|
||||
"default": "mdi:cpu-64-bit"
|
||||
},
|
||||
"node_max_disk": {
|
||||
"default": "mdi:harddisk"
|
||||
},
|
||||
"node_max_memory": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"node_memory": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"node_status": {
|
||||
"default": "mdi:server"
|
||||
},
|
||||
"vm_cpu": {
|
||||
"default": "mdi:cpu-64-bit"
|
||||
},
|
||||
"vm_disk": {
|
||||
"default": "mdi:harddisk"
|
||||
},
|
||||
"vm_max_cpu": {
|
||||
"default": "mdi:cpu-64-bit"
|
||||
},
|
||||
"vm_max_disk": {
|
||||
"default": "mdi:harddisk"
|
||||
},
|
||||
"vm_max_memory": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"vm_memory": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"vm_status": {
|
||||
"default": "mdi:server"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
"""Sensor platform for Proxmox VE integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
EntityCategory,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, UnitOfInformation
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData
|
||||
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ProxmoxNodeSensorEntityDescription(SensorEntityDescription):
|
||||
"""Class to hold Proxmox node sensor description."""
|
||||
|
||||
value_fn: Callable[[ProxmoxNodeData], StateType]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ProxmoxVMSensorEntityDescription(SensorEntityDescription):
|
||||
"""Class to hold Proxmox VM sensor description."""
|
||||
|
||||
value_fn: Callable[[dict[str, Any]], StateType]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ProxmoxContainerSensorEntityDescription(SensorEntityDescription):
|
||||
"""Class to hold Proxmox container sensor description."""
|
||||
|
||||
value_fn: Callable[[dict[str, Any]], StateType]
|
||||
|
||||
|
||||
NODE_SENSORS: tuple[ProxmoxNodeSensorEntityDescription, ...] = (
|
||||
ProxmoxNodeSensorEntityDescription(
|
||||
key="node_cpu",
|
||||
translation_key="node_cpu",
|
||||
value_fn=lambda data: data.node["cpu"] * 100,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=2,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxNodeSensorEntityDescription(
|
||||
key="node_max_cpu",
|
||||
translation_key="node_max_cpu",
|
||||
value_fn=lambda data: data.node["maxcpu"],
|
||||
),
|
||||
ProxmoxNodeSensorEntityDescription(
|
||||
key="node_disk",
|
||||
translation_key="node_disk",
|
||||
value_fn=lambda data: data.node["disk"],
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxNodeSensorEntityDescription(
|
||||
key="node_max_disk",
|
||||
translation_key="node_max_disk",
|
||||
value_fn=lambda data: data.node["maxdisk"],
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxNodeSensorEntityDescription(
|
||||
key="node_memory",
|
||||
translation_key="node_memory",
|
||||
value_fn=lambda data: data.node["mem"],
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxNodeSensorEntityDescription(
|
||||
key="node_max_memory",
|
||||
translation_key="node_max_memory",
|
||||
value_fn=lambda data: data.node["maxmem"],
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxNodeSensorEntityDescription(
|
||||
key="node_status",
|
||||
translation_key="node_status",
|
||||
value_fn=lambda data: data.node["status"],
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["online", "offline"],
|
||||
),
|
||||
)
|
||||
|
||||
VM_SENSORS: tuple[ProxmoxVMSensorEntityDescription, ...] = (
|
||||
ProxmoxVMSensorEntityDescription(
|
||||
key="vm_max_cpu",
|
||||
translation_key="vm_max_cpu",
|
||||
value_fn=lambda data: data["cpus"],
|
||||
),
|
||||
ProxmoxVMSensorEntityDescription(
|
||||
key="vm_cpu",
|
||||
translation_key="vm_cpu",
|
||||
value_fn=lambda data: data["cpu"] * 100,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=2,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxVMSensorEntityDescription(
|
||||
key="vm_memory",
|
||||
translation_key="vm_memory",
|
||||
value_fn=lambda data: data["mem"],
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxVMSensorEntityDescription(
|
||||
key="vm_max_memory",
|
||||
translation_key="vm_max_memory",
|
||||
value_fn=lambda data: data["maxmem"],
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxVMSensorEntityDescription(
|
||||
key="vm_disk",
|
||||
translation_key="vm_disk",
|
||||
value_fn=lambda data: data["disk"],
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxVMSensorEntityDescription(
|
||||
key="vm_max_disk",
|
||||
translation_key="vm_max_disk",
|
||||
value_fn=lambda data: data["maxdisk"],
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxVMSensorEntityDescription(
|
||||
key="vm_status",
|
||||
translation_key="vm_status",
|
||||
value_fn=lambda data: data["status"],
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["running", "stopped", "suspended"],
|
||||
),
|
||||
)
|
||||
|
||||
CONTAINER_SENSORS: tuple[ProxmoxContainerSensorEntityDescription, ...] = (
|
||||
ProxmoxContainerSensorEntityDescription(
|
||||
key="container_max_cpu",
|
||||
translation_key="container_max_cpu",
|
||||
value_fn=lambda data: data["cpus"],
|
||||
),
|
||||
ProxmoxContainerSensorEntityDescription(
|
||||
key="container_cpu",
|
||||
translation_key="container_cpu",
|
||||
value_fn=lambda data: data["cpu"] * 100,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=2,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxContainerSensorEntityDescription(
|
||||
key="container_memory",
|
||||
translation_key="container_memory",
|
||||
value_fn=lambda data: data["mem"],
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxContainerSensorEntityDescription(
|
||||
key="container_max_memory",
|
||||
translation_key="container_max_memory",
|
||||
value_fn=lambda data: data["maxmem"],
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxContainerSensorEntityDescription(
|
||||
key="container_disk",
|
||||
translation_key="container_disk",
|
||||
value_fn=lambda data: data["disk"],
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxContainerSensorEntityDescription(
|
||||
key="container_max_disk",
|
||||
translation_key="container_max_disk",
|
||||
value_fn=lambda data: data["maxdisk"],
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ProxmoxContainerSensorEntityDescription(
|
||||
key="container_status",
|
||||
translation_key="container_status",
|
||||
value_fn=lambda data: data["status"],
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["running", "stopped", "suspended"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ProxmoxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Proxmox VE sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None:
|
||||
"""Add new node sensors."""
|
||||
async_add_entities(
|
||||
ProxmoxNodeSensor(coordinator, entity_description, node)
|
||||
for node in nodes
|
||||
for entity_description in NODE_SENSORS
|
||||
)
|
||||
|
||||
def _async_add_new_vms(
|
||||
vms: list[tuple[ProxmoxNodeData, dict[str, Any]]],
|
||||
) -> None:
|
||||
"""Add new VM sensors."""
|
||||
async_add_entities(
|
||||
ProxmoxVMSensor(coordinator, entity_description, vm, node_data)
|
||||
for (node_data, vm) in vms
|
||||
for entity_description in VM_SENSORS
|
||||
)
|
||||
|
||||
def _async_add_new_containers(
|
||||
containers: list[tuple[ProxmoxNodeData, dict[str, Any]]],
|
||||
) -> None:
|
||||
"""Add new container sensors."""
|
||||
async_add_entities(
|
||||
ProxmoxContainerSensor(
|
||||
coordinator, entity_description, container, node_data
|
||||
)
|
||||
for (node_data, container) in containers
|
||||
for entity_description in CONTAINER_SENSORS
|
||||
)
|
||||
|
||||
coordinator.new_nodes_callbacks.append(_async_add_new_nodes)
|
||||
coordinator.new_vms_callbacks.append(_async_add_new_vms)
|
||||
coordinator.new_containers_callbacks.append(_async_add_new_containers)
|
||||
|
||||
_async_add_new_nodes(
|
||||
[
|
||||
node_data
|
||||
for node_data in coordinator.data.values()
|
||||
if node_data.node["node"] in coordinator.known_nodes
|
||||
]
|
||||
)
|
||||
_async_add_new_vms(
|
||||
[
|
||||
(node_data, vm_data)
|
||||
for node_data in coordinator.data.values()
|
||||
for vmid, vm_data in node_data.vms.items()
|
||||
if (node_data.node["node"], vmid) in coordinator.known_vms
|
||||
]
|
||||
)
|
||||
_async_add_new_containers(
|
||||
[
|
||||
(node_data, container_data)
|
||||
for node_data in coordinator.data.values()
|
||||
for vmid, container_data in node_data.containers.items()
|
||||
if (node_data.node["node"], vmid) in coordinator.known_containers
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class ProxmoxNodeSensor(ProxmoxNodeEntity, SensorEntity):
|
||||
"""Representation of a Proxmox VE node sensor."""
|
||||
|
||||
entity_description: ProxmoxNodeSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxNodeSensorEntityDescription,
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator, node_data)
|
||||
self.entity_description = entity_description
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{node_data.node['id']}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the native value of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data[self.device_name])
|
||||
|
||||
|
||||
class ProxmoxVMSensor(ProxmoxVMEntity, SensorEntity):
|
||||
"""Represents a Proxmox VE VM sensor."""
|
||||
|
||||
entity_description: ProxmoxVMSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxVMSensorEntityDescription,
|
||||
vm_data: dict[str, Any],
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox VM sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, vm_data, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the native value of the sensor."""
|
||||
return self.entity_description.value_fn(self.vm_data)
|
||||
|
||||
|
||||
class ProxmoxContainerSensor(ProxmoxContainerEntity, SensorEntity):
|
||||
"""Represents a Proxmox VE container sensor."""
|
||||
|
||||
entity_description: ProxmoxContainerSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxContainerSensorEntityDescription,
|
||||
container_data: dict[str, Any],
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox container sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, container_data, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the native value of the sensor."""
|
||||
return self.entity_description.value_fn(self.container_data)
|
||||
@@ -77,85 +77,6 @@
|
||||
"stop_all": {
|
||||
"name": "Stop all"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"container_cpu": {
|
||||
"name": "CPU usage"
|
||||
},
|
||||
"container_disk": {
|
||||
"name": "Disk usage"
|
||||
},
|
||||
"container_max_cpu": {
|
||||
"name": "Max CPU"
|
||||
},
|
||||
"container_max_disk": {
|
||||
"name": "Max disk usage"
|
||||
},
|
||||
"container_max_memory": {
|
||||
"name": "Max memory usage"
|
||||
},
|
||||
"container_memory": {
|
||||
"name": "Memory usage"
|
||||
},
|
||||
"container_status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"suspended": "Suspended"
|
||||
}
|
||||
},
|
||||
"node_cpu": {
|
||||
"name": "CPU usage"
|
||||
},
|
||||
"node_disk": {
|
||||
"name": "Disk usage"
|
||||
},
|
||||
"node_max_cpu": {
|
||||
"name": "Max CPU"
|
||||
},
|
||||
"node_max_disk": {
|
||||
"name": "Max disk usage"
|
||||
},
|
||||
"node_max_memory": {
|
||||
"name": "Max memory usage"
|
||||
},
|
||||
"node_memory": {
|
||||
"name": "Memory usage"
|
||||
},
|
||||
"node_status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"offline": "Offline",
|
||||
"online": "Online"
|
||||
}
|
||||
},
|
||||
"vm_cpu": {
|
||||
"name": "CPU usage"
|
||||
},
|
||||
"vm_disk": {
|
||||
"name": "Disk usage"
|
||||
},
|
||||
"vm_max_cpu": {
|
||||
"name": "Max CPU"
|
||||
},
|
||||
"vm_max_disk": {
|
||||
"name": "Max disk usage"
|
||||
},
|
||||
"vm_max_memory": {
|
||||
"name": "Max memory usage"
|
||||
},
|
||||
"vm_memory": {
|
||||
"name": "Memory usage"
|
||||
},
|
||||
"vm_status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"suspended": "Suspended"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -188,10 +109,6 @@
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue_cannot_connect": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
|
||||
"title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
|
||||
},
|
||||
"deprecated_yaml_import_issue_connect_timeout": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection timeout occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
|
||||
"title": "The {integration_title} YAML configuration is being removed"
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from pyrainbird.async_client import AsyncRainbirdController, CreateController
|
||||
from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
|
||||
from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -77,10 +77,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) ->
|
||||
clientsession = async_create_clientsession()
|
||||
_async_register_clientsession_shutdown(hass, entry, clientsession)
|
||||
|
||||
controller = CreateController(
|
||||
clientsession,
|
||||
entry.data[CONF_HOST],
|
||||
entry.data[CONF_PASSWORD],
|
||||
controller = AsyncRainbirdController(
|
||||
AsyncRainbirdClient(
|
||||
clientsession,
|
||||
entry.data[CONF_HOST],
|
||||
entry.data[CONF_PASSWORD],
|
||||
)
|
||||
)
|
||||
|
||||
if not (await _async_fix_unique_id(hass, controller, entry)):
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyrainbird.async_client import CreateController
|
||||
from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
|
||||
from pyrainbird.data import WifiParams
|
||||
from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException
|
||||
import voluptuous as vol
|
||||
@@ -137,7 +137,13 @@ class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
Raises a ConfigFlowError on failure.
|
||||
"""
|
||||
clientsession = async_create_clientsession()
|
||||
controller = CreateController(clientsession, host, password)
|
||||
controller = AsyncRainbirdController(
|
||||
AsyncRainbirdClient(
|
||||
clientsession,
|
||||
host,
|
||||
password,
|
||||
)
|
||||
)
|
||||
try:
|
||||
async with asyncio.timeout(TIMEOUT_SECONDS):
|
||||
return await asyncio.gather(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyrainbird"],
|
||||
"requirements": ["pyrainbird==6.1.0"]
|
||||
"requirements": ["pyrainbird==6.0.5"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from roborock.data import CleanFluidStatus, RoborockStateCode
|
||||
from roborock.roborock_message import RoborockZeoProtocol
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -16,15 +15,9 @@ from homeassistant.components.binary_sensor import (
|
||||
from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .coordinator import (
|
||||
RoborockConfigEntry,
|
||||
RoborockDataUpdateCoordinator,
|
||||
RoborockDataUpdateCoordinatorA01,
|
||||
RoborockWashingMachineUpdateCoordinator,
|
||||
)
|
||||
from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1
|
||||
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
|
||||
from .entity import RoborockCoordinatedEntityV1
|
||||
from .models import DeviceState
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -41,14 +34,6 @@ class RoborockBinarySensorDescription(BinarySensorEntityDescription):
|
||||
"""Whether this sensor is for the dock."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription):
|
||||
"""A class that describes Roborock A01 binary sensors."""
|
||||
|
||||
data_protocol: RoborockZeoProtocol
|
||||
value_fn: Callable[[StateType], bool]
|
||||
|
||||
|
||||
BINARY_SENSOR_DESCRIPTIONS = [
|
||||
RoborockBinarySensorDescription(
|
||||
key="dry_status",
|
||||
@@ -126,33 +111,13 @@ BINARY_SENSOR_DESCRIPTIONS = [
|
||||
]
|
||||
|
||||
|
||||
ZEO_BINARY_SENSOR_DESCRIPTIONS: list[RoborockBinarySensorDescriptionA01] = [
|
||||
RoborockBinarySensorDescriptionA01(
|
||||
key="detergent_empty",
|
||||
data_protocol=RoborockZeoProtocol.DETERGENT_EMPTY,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
translation_key="detergent_empty",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=bool,
|
||||
),
|
||||
RoborockBinarySensorDescriptionA01(
|
||||
key="softener_empty",
|
||||
data_protocol=RoborockZeoProtocol.SOFTENER_EMPTY,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
translation_key="softener_empty",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=bool,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: RoborockConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Roborock vacuum binary sensors."""
|
||||
entities: list[BinarySensorEntity] = [
|
||||
async_add_entities(
|
||||
RoborockBinarySensorEntity(
|
||||
coordinator,
|
||||
description,
|
||||
@@ -160,18 +125,7 @@ async def async_setup_entry(
|
||||
for coordinator in config_entry.runtime_data.v1
|
||||
for description in BINARY_SENSOR_DESCRIPTIONS
|
||||
if description.value_fn(coordinator.data) is not None
|
||||
]
|
||||
entities.extend(
|
||||
RoborockBinarySensorEntityA01(
|
||||
coordinator,
|
||||
description,
|
||||
)
|
||||
for coordinator in config_entry.runtime_data.a01
|
||||
if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator)
|
||||
for description in ZEO_BINARY_SENSOR_DESCRIPTIONS
|
||||
if description.data_protocol in coordinator.request_protocols
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity):
|
||||
@@ -196,24 +150,3 @@ class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity
|
||||
def is_on(self) -> bool:
|
||||
"""Return the value reported by the sensor."""
|
||||
return bool(self.entity_description.value_fn(self.coordinator.data))
|
||||
|
||||
|
||||
class RoborockBinarySensorEntityA01(RoborockCoordinatedEntityA01, BinarySensorEntity):
|
||||
"""Representation of a A01 Roborock binary sensor."""
|
||||
|
||||
entity_description: RoborockBinarySensorDescriptionA01
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RoborockDataUpdateCoordinatorA01,
|
||||
description: RoborockBinarySensorDescriptionA01,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.entity_description = description
|
||||
super().__init__(f"{description.key}_{coordinator.duid_slug}", coordinator)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the value reported by the sensor."""
|
||||
value = self.coordinator.data[self.entity_description.data_protocol]
|
||||
return self.entity_description.value_fn(value)
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Any
|
||||
|
||||
from roborock.devices.traits.v1.consumeable import ConsumableAttribute
|
||||
from roborock.exceptions import RoborockException
|
||||
from roborock.roborock_message import RoborockZeoProtocol
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
@@ -19,13 +18,8 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
RoborockConfigEntry,
|
||||
RoborockDataUpdateCoordinator,
|
||||
RoborockDataUpdateCoordinatorA01,
|
||||
RoborockWashingMachineUpdateCoordinator,
|
||||
)
|
||||
from .entity import RoborockCoordinatedEntityA01, RoborockEntity, RoborockEntityV1
|
||||
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
|
||||
from .entity import RoborockEntity, RoborockEntityV1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,32 +65,6 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class RoborockButtonDescriptionA01(ButtonEntityDescription):
|
||||
"""Describes a Roborock A01 button entity."""
|
||||
|
||||
data_protocol: RoborockZeoProtocol
|
||||
|
||||
|
||||
ZEO_BUTTON_DESCRIPTIONS = [
|
||||
RoborockButtonDescriptionA01(
|
||||
key="start",
|
||||
data_protocol=RoborockZeoProtocol.START,
|
||||
translation_key="start",
|
||||
),
|
||||
RoborockButtonDescriptionA01(
|
||||
key="pause",
|
||||
data_protocol=RoborockZeoProtocol.PAUSE,
|
||||
translation_key="pause",
|
||||
),
|
||||
RoborockButtonDescriptionA01(
|
||||
key="shutdown",
|
||||
data_protocol=RoborockZeoProtocol.SHUTDOWN,
|
||||
translation_key="shutdown",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: RoborockConfigEntry,
|
||||
@@ -130,15 +98,6 @@ async def async_setup_entry(
|
||||
)
|
||||
for routine in routines
|
||||
),
|
||||
(
|
||||
RoborockButtonEntityA01(
|
||||
coordinator,
|
||||
description,
|
||||
)
|
||||
for coordinator in config_entry.runtime_data.a01
|
||||
if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator)
|
||||
for description in ZEO_BUTTON_DESCRIPTIONS
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -201,35 +160,3 @@ class RoborockRoutineButtonEntity(RoborockEntity, ButtonEntity):
|
||||
async def async_press(self, **kwargs: Any) -> None:
|
||||
"""Press the button."""
|
||||
await self._coordinator.execute_routines(self._routine_id)
|
||||
|
||||
|
||||
class RoborockButtonEntityA01(RoborockCoordinatedEntityA01, ButtonEntity):
|
||||
"""A class to define Roborock A01 button entities."""
|
||||
|
||||
entity_description: RoborockButtonDescriptionA01
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RoborockDataUpdateCoordinatorA01,
|
||||
entity_description: RoborockButtonDescriptionA01,
|
||||
) -> None:
|
||||
"""Create an A01 button entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(
|
||||
f"{entity_description.key}_{coordinator.duid_slug}", coordinator
|
||||
)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
try:
|
||||
await self.coordinator.api.set_value( # type: ignore[attr-defined]
|
||||
self.entity_description.data_protocol,
|
||||
1,
|
||||
)
|
||||
except RoborockException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="button_press_failed",
|
||||
) from err
|
||||
finally:
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -432,18 +432,6 @@ class RoborockWashingMachineUpdateCoordinator(
|
||||
RoborockZeoProtocol.COUNTDOWN,
|
||||
RoborockZeoProtocol.WASHING_LEFT,
|
||||
RoborockZeoProtocol.ERROR,
|
||||
RoborockZeoProtocol.TIMES_AFTER_CLEAN,
|
||||
RoborockZeoProtocol.DETERGENT_EMPTY,
|
||||
RoborockZeoProtocol.SOFTENER_EMPTY,
|
||||
RoborockZeoProtocol.DETERGENT_TYPE,
|
||||
RoborockZeoProtocol.SOFTENER_TYPE,
|
||||
RoborockZeoProtocol.MODE,
|
||||
RoborockZeoProtocol.PROGRAM,
|
||||
RoborockZeoProtocol.TEMP,
|
||||
RoborockZeoProtocol.RINSE_TIMES,
|
||||
RoborockZeoProtocol.SPIN_LEVEL,
|
||||
RoborockZeoProtocol.DRYING_MODE,
|
||||
RoborockZeoProtocol.SOUND_SET,
|
||||
]
|
||||
|
||||
async def _async_update_data(
|
||||
|
||||
@@ -3,35 +3,21 @@
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from roborock import B01Props, CleanTypeMapping
|
||||
from roborock.data import (
|
||||
RoborockDockDustCollectionModeCode,
|
||||
RoborockEnum,
|
||||
WaterLevelMapping,
|
||||
ZeoDetergentType,
|
||||
ZeoDryingMode,
|
||||
ZeoMode,
|
||||
ZeoProgram,
|
||||
ZeoRinse,
|
||||
ZeoSoftenerType,
|
||||
ZeoSpin,
|
||||
ZeoTemperature,
|
||||
)
|
||||
from roborock.data import RoborockDockDustCollectionModeCode, WaterLevelMapping
|
||||
from roborock.devices.traits.b01 import Q7PropertiesApi
|
||||
from roborock.devices.traits.v1 import PropertiesApi
|
||||
from roborock.devices.traits.v1.home import HomeTrait
|
||||
from roborock.devices.traits.v1.maps import MapsTrait
|
||||
from roborock.exceptions import RoborockException
|
||||
from roborock.roborock_message import RoborockZeoProtocol
|
||||
from roborock.roborock_typing import RoborockCommand
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, MAP_SLEEP
|
||||
@@ -39,18 +25,11 @@ from .coordinator import (
|
||||
RoborockB01Q7UpdateCoordinator,
|
||||
RoborockConfigEntry,
|
||||
RoborockDataUpdateCoordinator,
|
||||
RoborockDataUpdateCoordinatorA01,
|
||||
)
|
||||
from .entity import (
|
||||
RoborockCoordinatedEntityA01,
|
||||
RoborockCoordinatedEntityB01Q7,
|
||||
RoborockCoordinatedEntityV1,
|
||||
)
|
||||
from .entity import RoborockCoordinatedEntityB01Q7, RoborockCoordinatedEntityV1
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class RoborockSelectDescription(SelectEntityDescription):
|
||||
@@ -86,16 +65,6 @@ class RoborockB01SelectDescription(SelectEntityDescription):
|
||||
"""Function to get all options of the select entity or returns None if not supported."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class RoborockSelectDescriptionA01(SelectEntityDescription):
|
||||
"""Class to describe a Roborock A01 select entity."""
|
||||
|
||||
# The protocol that the select entity will send to the api.
|
||||
data_protocol: RoborockZeoProtocol
|
||||
# Enum class for the select entity
|
||||
enum_class: type[RoborockEnum]
|
||||
|
||||
|
||||
B01_SELECT_DESCRIPTIONS: list[RoborockB01SelectDescription] = [
|
||||
RoborockB01SelectDescription(
|
||||
key="water_flow",
|
||||
@@ -170,66 +139,6 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [
|
||||
]
|
||||
|
||||
|
||||
A01_SELECT_DESCRIPTIONS: list[RoborockSelectDescriptionA01] = [
|
||||
RoborockSelectDescriptionA01(
|
||||
key="program",
|
||||
data_protocol=RoborockZeoProtocol.PROGRAM,
|
||||
translation_key="program",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
enum_class=ZeoProgram,
|
||||
),
|
||||
RoborockSelectDescriptionA01(
|
||||
key="mode",
|
||||
data_protocol=RoborockZeoProtocol.MODE,
|
||||
translation_key="mode",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
enum_class=ZeoMode,
|
||||
),
|
||||
RoborockSelectDescriptionA01(
|
||||
key="temperature",
|
||||
data_protocol=RoborockZeoProtocol.TEMP,
|
||||
translation_key="temperature",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
enum_class=ZeoTemperature,
|
||||
),
|
||||
RoborockSelectDescriptionA01(
|
||||
key="drying_mode",
|
||||
data_protocol=RoborockZeoProtocol.DRYING_MODE,
|
||||
translation_key="drying_mode",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
enum_class=ZeoDryingMode,
|
||||
),
|
||||
RoborockSelectDescriptionA01(
|
||||
key="spin_level",
|
||||
data_protocol=RoborockZeoProtocol.SPIN_LEVEL,
|
||||
translation_key="spin_level",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
enum_class=ZeoSpin,
|
||||
),
|
||||
RoborockSelectDescriptionA01(
|
||||
key="rinse_times",
|
||||
data_protocol=RoborockZeoProtocol.RINSE_TIMES,
|
||||
translation_key="rinse_times",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
enum_class=ZeoRinse,
|
||||
),
|
||||
RoborockSelectDescriptionA01(
|
||||
key="detergent_type",
|
||||
data_protocol=RoborockZeoProtocol.DETERGENT_TYPE,
|
||||
translation_key="detergent_type",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
enum_class=ZeoDetergentType,
|
||||
),
|
||||
RoborockSelectDescriptionA01(
|
||||
key="softener_type",
|
||||
data_protocol=RoborockZeoProtocol.SOFTENER_TYPE,
|
||||
translation_key="softener_type",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
enum_class=ZeoSoftenerType,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: RoborockConfigEntry,
|
||||
@@ -260,12 +169,6 @@ async def async_setup_entry(
|
||||
for description in B01_SELECT_DESCRIPTIONS
|
||||
if (options := description.options_lambda(coordinator.api)) is not None
|
||||
)
|
||||
async_add_entities(
|
||||
RoborockSelectEntityA01(coordinator, description)
|
||||
for coordinator in config_entry.runtime_data.a01
|
||||
for description in A01_SELECT_DESCRIPTIONS
|
||||
if description.data_protocol in coordinator.request_protocols
|
||||
)
|
||||
|
||||
|
||||
class RoborockB01SelectEntity(RoborockCoordinatedEntityB01Q7, SelectEntity):
|
||||
@@ -405,64 +308,3 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
|
||||
if current_map_info := self._home_trait.current_map_data:
|
||||
return current_map_info.name or f"Map {current_map_info.map_flag}"
|
||||
return None
|
||||
|
||||
|
||||
class RoborockSelectEntityA01(RoborockCoordinatedEntityA01, SelectEntity):
|
||||
"""A class to let you set options on a Roborock A01 device."""
|
||||
|
||||
entity_description: RoborockSelectDescriptionA01
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RoborockDataUpdateCoordinatorA01,
|
||||
entity_description: RoborockSelectDescriptionA01,
|
||||
) -> None:
|
||||
"""Create an A01 select entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(
|
||||
f"{entity_description.key}_{coordinator.duid_slug}",
|
||||
coordinator,
|
||||
)
|
||||
self._attr_options = list(entity_description.enum_class.keys())
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the option."""
|
||||
# Get the protocol value for the selected option
|
||||
option_values = self.entity_description.enum_class.as_dict()
|
||||
if option not in option_values:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="select_option_failed",
|
||||
)
|
||||
value = option_values[option]
|
||||
try:
|
||||
await self.coordinator.api.set_value( # type: ignore[attr-defined]
|
||||
self.entity_description.data_protocol,
|
||||
value,
|
||||
)
|
||||
except RoborockException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={
|
||||
"command": self.entity_description.key,
|
||||
},
|
||||
) from err
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Get the current status of the select entity from coordinator data."""
|
||||
if self.entity_description.data_protocol not in self.coordinator.data:
|
||||
return None
|
||||
|
||||
current_value = self.coordinator.data[self.entity_description.data_protocol]
|
||||
if current_value is None:
|
||||
return None
|
||||
_LOGGER.debug(
|
||||
"current_value: %s for %s",
|
||||
current_value,
|
||||
self.entity_description.key,
|
||||
)
|
||||
return str(current_value)
|
||||
|
||||
@@ -37,8 +37,6 @@ from .coordinator import (
|
||||
RoborockConfigEntry,
|
||||
RoborockDataUpdateCoordinator,
|
||||
RoborockDataUpdateCoordinatorA01,
|
||||
RoborockWashingMachineUpdateCoordinator,
|
||||
RoborockWetDryVacUpdateCoordinator,
|
||||
)
|
||||
from .entity import (
|
||||
RoborockCoordinatedEntityA01,
|
||||
@@ -254,7 +252,7 @@ SENSOR_DESCRIPTIONS = [
|
||||
),
|
||||
]
|
||||
|
||||
DYAD_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
|
||||
A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
|
||||
RoborockSensorDescriptionA01(
|
||||
key="status",
|
||||
data_protocol=RoborockDyadDataProtocol.STATUS,
|
||||
@@ -305,9 +303,6 @@ DYAD_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
|
||||
translation_key="total_cleaning_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
]
|
||||
|
||||
ZEO_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
|
||||
RoborockSensorDescriptionA01(
|
||||
key="state",
|
||||
data_protocol=RoborockZeoProtocol.STATE,
|
||||
@@ -340,12 +335,6 @@ ZEO_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
options=ZeoError.keys(),
|
||||
),
|
||||
RoborockSensorDescriptionA01(
|
||||
key="times_after_clean",
|
||||
data_protocol=RoborockZeoProtocol.TIMES_AFTER_CLEAN,
|
||||
translation_key="times_after_clean",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
]
|
||||
|
||||
Q7_B01_SENSOR_DESCRIPTIONS = [
|
||||
@@ -429,18 +418,7 @@ async def async_setup_entry(
|
||||
description,
|
||||
)
|
||||
for coordinator in coordinators.a01
|
||||
if isinstance(coordinator, RoborockWetDryVacUpdateCoordinator)
|
||||
for description in DYAD_SENSOR_DESCRIPTIONS
|
||||
if description.data_protocol in coordinator.request_protocols
|
||||
)
|
||||
entities.extend(
|
||||
RoborockSensorEntityA01(
|
||||
coordinator,
|
||||
description,
|
||||
)
|
||||
for coordinator in coordinators.a01
|
||||
if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator)
|
||||
for description in ZEO_SENSOR_DESCRIPTIONS
|
||||
for description in A01_SENSOR_DESCRIPTIONS
|
||||
if description.data_protocol in coordinator.request_protocols
|
||||
)
|
||||
entities.extend(
|
||||
|
||||
@@ -50,13 +50,6 @@
|
||||
"clean_fluid_empty": {
|
||||
"name": "Cleaning fluid"
|
||||
},
|
||||
"detergent_empty": {
|
||||
"name": "Detergent",
|
||||
"state": {
|
||||
"off": "Available",
|
||||
"on": "[%key:common::state::empty%]"
|
||||
}
|
||||
},
|
||||
"dirty_box_full": {
|
||||
"name": "Dirty water box"
|
||||
},
|
||||
@@ -69,13 +62,6 @@
|
||||
"mop_drying_status": {
|
||||
"name": "Mop drying"
|
||||
},
|
||||
"softener_empty": {
|
||||
"name": "Softener",
|
||||
"state": {
|
||||
"off": "Available",
|
||||
"on": "[%key:common::state::empty%]"
|
||||
}
|
||||
},
|
||||
"water_box_attached": {
|
||||
"name": "Water box attached"
|
||||
},
|
||||
@@ -84,9 +70,6 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"pause": {
|
||||
"name": "Pause"
|
||||
},
|
||||
"reset_air_filter_consumable": {
|
||||
"name": "Reset air filter consumable"
|
||||
},
|
||||
@@ -98,12 +81,6 @@
|
||||
},
|
||||
"reset_side_brush_consumable": {
|
||||
"name": "Reset side brush consumable"
|
||||
},
|
||||
"shutdown": {
|
||||
"name": "Shutdown"
|
||||
},
|
||||
"start": {
|
||||
"name": "Start"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
@@ -120,25 +97,6 @@
|
||||
"vacuum": "Vacuum only"
|
||||
}
|
||||
},
|
||||
"detergent_type": {
|
||||
"name": "Detergent type",
|
||||
"state": {
|
||||
"empty": "[%key:common::state::empty%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]"
|
||||
}
|
||||
},
|
||||
"drying_mode": {
|
||||
"name": "Drying mode",
|
||||
"state": {
|
||||
"iron": "Iron",
|
||||
"none": "No drying",
|
||||
"quick": "Quick",
|
||||
"store": "Store",
|
||||
"time_dry": "Time dry"
|
||||
}
|
||||
},
|
||||
"dust_collection_mode": {
|
||||
"name": "Empty mode",
|
||||
"state": {
|
||||
@@ -148,19 +106,6 @@
|
||||
"smart": "Smart"
|
||||
}
|
||||
},
|
||||
"mode": {
|
||||
"name": "Operating mode",
|
||||
"state": {
|
||||
"drain": "Drain",
|
||||
"dry": "Dry",
|
||||
"heavy": "Heavy",
|
||||
"pre_wash": "Pre-wash",
|
||||
"rinse_spin": "Rinse & spin",
|
||||
"spin": "Spin",
|
||||
"wash": "Wash",
|
||||
"wash_and_dry": "Wash and dry"
|
||||
}
|
||||
},
|
||||
"mop_intensity": {
|
||||
"name": "Mop intensity",
|
||||
"state": {
|
||||
@@ -193,90 +138,9 @@
|
||||
"standard": "Standard"
|
||||
}
|
||||
},
|
||||
"program": {
|
||||
"name": "Wash program",
|
||||
"state": {
|
||||
"air_refresh": "Air refresh",
|
||||
"anti_allergen": "Anti-allergen",
|
||||
"anti_mites": "Anti-mites",
|
||||
"baby_care": "Baby care",
|
||||
"bedding": "Bedding",
|
||||
"boiling_wash": "Boiling wash",
|
||||
"bra": "Bra",
|
||||
"cotton_linen": "Cotton/Linen",
|
||||
"custom": "Custom",
|
||||
"down": "Down",
|
||||
"down_clean": "Down clean",
|
||||
"exo_40_60": "Exo 40/60",
|
||||
"gentle": "Gentle",
|
||||
"intensive": "Intensive",
|
||||
"new_clothes": "New clothes",
|
||||
"night": "Night",
|
||||
"panties": "Panties",
|
||||
"quick": "Quick",
|
||||
"rinse_and_spin": "Rinse and spin",
|
||||
"sanitize": "Sanitize",
|
||||
"season": "Season",
|
||||
"shirts": "Shirts",
|
||||
"silk": "Silk",
|
||||
"socks": "Socks",
|
||||
"sportswear": "Sportswear",
|
||||
"stain_removal": "Stain removal",
|
||||
"standard": "Standard",
|
||||
"synthetics": "Synthetics",
|
||||
"t_shirts": "T-shirts",
|
||||
"towels": "Towels",
|
||||
"twenty_c": "20°C",
|
||||
"underwear": "Underwear",
|
||||
"warming": "Warming",
|
||||
"wool": "Wool"
|
||||
}
|
||||
},
|
||||
"rinse_times": {
|
||||
"name": "Rinse times",
|
||||
"state": {
|
||||
"high": "4",
|
||||
"low": "2",
|
||||
"max": "5",
|
||||
"mid": "3",
|
||||
"min": "1",
|
||||
"none": "Default"
|
||||
}
|
||||
},
|
||||
"selected_map": {
|
||||
"name": "Selected map"
|
||||
},
|
||||
"softener_type": {
|
||||
"name": "Softener type",
|
||||
"state": {
|
||||
"empty": "[%key:common::state::empty%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]"
|
||||
}
|
||||
},
|
||||
"spin_level": {
|
||||
"name": "Spin level",
|
||||
"state": {
|
||||
"high": "1000 RPM",
|
||||
"max": "1400 RPM",
|
||||
"mid": "800 RPM",
|
||||
"none": "Default",
|
||||
"very_high": "1200 RPM",
|
||||
"very_low": "600 RPM"
|
||||
}
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Water temperature",
|
||||
"state": {
|
||||
"30": "30°C",
|
||||
"40": "40°C",
|
||||
"60": "60°C",
|
||||
"90": "90°C",
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"cold": "Cold"
|
||||
}
|
||||
},
|
||||
"water_flow": {
|
||||
"name": "Water flow",
|
||||
"state": {
|
||||
@@ -443,9 +307,6 @@
|
||||
"strainer_time_left": {
|
||||
"name": "Strainer time left"
|
||||
},
|
||||
"times_after_clean": {
|
||||
"name": "Times after clean"
|
||||
},
|
||||
"total_cleaning_area": {
|
||||
"name": "Total cleaning area"
|
||||
},
|
||||
@@ -514,14 +375,14 @@
|
||||
"communication_error": "Communication error",
|
||||
"door_lock_error": "Door lock error",
|
||||
"drain_error": "Drain error",
|
||||
"drying_error": "Drying error: check air inlet temperature sensor",
|
||||
"drying_error_e_12": "Drying error: check air outlet temperature sensor",
|
||||
"drying_error": "Drying error",
|
||||
"drying_error_e_12": "Drying error E12",
|
||||
"drying_error_e_13": "Drying error E13",
|
||||
"drying_error_e_14": "Drying error: check inlet condenser temperature sensor",
|
||||
"drying_error_e_15": "Drying error: check heating element or turntable",
|
||||
"drying_error_e_16": "Drying error: check drying fan",
|
||||
"drying_error_restart": "Drying error: restart the washer",
|
||||
"drying_error_water_flow": "Drying error: check water flow",
|
||||
"drying_error_e_14": "Drying error E14",
|
||||
"drying_error_e_15": "Drying error E15",
|
||||
"drying_error_e_16": "Drying error E16",
|
||||
"drying_error_restart": "Restart the washer",
|
||||
"drying_error_water_flow": "Check water flow",
|
||||
"heating_error": "Heating error",
|
||||
"inverter_error": "Inverter error",
|
||||
"none": "[%key:component::roborock::entity::sensor::vacuum_error::state::none%]",
|
||||
@@ -559,9 +420,6 @@
|
||||
"off_peak_switch": {
|
||||
"name": "Off-peak charging"
|
||||
},
|
||||
"sound_setting": {
|
||||
"name": "Sound setting"
|
||||
},
|
||||
"status_indicator": {
|
||||
"name": "Status indicator light"
|
||||
}
|
||||
@@ -606,9 +464,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"button_press_failed": {
|
||||
"message": "Failed to press button"
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Error while calling {command}"
|
||||
},
|
||||
@@ -627,6 +482,9 @@
|
||||
"mqtt_unauthorized": {
|
||||
"message": "Roborock MQTT servers rejected the connection due to rate limiting or invalid credentials. You may either attempt to reauthenticate or wait and reload the integration."
|
||||
},
|
||||
"multiple_maps_in_clean": {
|
||||
"message": "All segments must belong to the same map. Got segments from maps: {map_flags}"
|
||||
},
|
||||
"no_coordinators": {
|
||||
"message": "No devices were able to successfully setup"
|
||||
},
|
||||
@@ -639,9 +497,6 @@
|
||||
"segment_id_parse_error": {
|
||||
"message": "Invalid segment ID format: {segment_id}"
|
||||
},
|
||||
"select_option_failed": {
|
||||
"message": "Failed to set selected option"
|
||||
},
|
||||
"update_data_fail": {
|
||||
"message": "Failed to update data"
|
||||
},
|
||||
@@ -655,6 +510,7 @@
|
||||
"title": "Cloud API used"
|
||||
}
|
||||
},
|
||||
|
||||
"options": {
|
||||
"step": {
|
||||
"drawables": {
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Any
|
||||
from roborock.devices.traits.v1 import PropertiesApi
|
||||
from roborock.devices.traits.v1.common import RoborockSwitchBase
|
||||
from roborock.exceptions import RoborockException
|
||||
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
@@ -19,12 +18,8 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
RoborockConfigEntry,
|
||||
RoborockDataUpdateCoordinator,
|
||||
RoborockDataUpdateCoordinatorA01,
|
||||
)
|
||||
from .entity import RoborockCoordinatedEntityA01, RoborockEntityV1
|
||||
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
|
||||
from .entity import RoborockEntityV1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -72,30 +67,12 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class RoborockSwitchDescriptionA01(SwitchEntityDescription):
|
||||
"""Class to describe a Roborock A01 switch entity."""
|
||||
|
||||
data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol
|
||||
|
||||
|
||||
A01_SWITCH_DESCRIPTIONS: list[RoborockSwitchDescriptionA01] = [
|
||||
RoborockSwitchDescriptionA01(
|
||||
key="sound_setting",
|
||||
data_protocol=RoborockZeoProtocol.SOUND_SET,
|
||||
translation_key="sound_setting",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: RoborockConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Roborock switch platform."""
|
||||
# V1 switches - using trait pattern from HEAD
|
||||
async_add_entities(
|
||||
[
|
||||
RoborockSwitch(
|
||||
@@ -110,17 +87,6 @@ async def async_setup_entry(
|
||||
]
|
||||
)
|
||||
|
||||
# A01 switches
|
||||
async_add_entities(
|
||||
RoborockSwitchA01(
|
||||
coordinator,
|
||||
description,
|
||||
)
|
||||
for coordinator in config_entry.runtime_data.a01
|
||||
for description in A01_SWITCH_DESCRIPTIONS
|
||||
if description.data_protocol in coordinator.request_protocols
|
||||
)
|
||||
|
||||
|
||||
class RoborockSwitch(RoborockEntityV1, SwitchEntity):
|
||||
"""A class to let you turn functionality on Roborock devices on and off that does need a coordinator."""
|
||||
@@ -171,52 +137,3 @@ class RoborockSwitch(RoborockEntityV1, SwitchEntity):
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if entity is on."""
|
||||
return self._trait.is_on
|
||||
|
||||
|
||||
class RoborockSwitchA01(RoborockCoordinatedEntityA01, SwitchEntity):
|
||||
"""A class to let you turn functionality on Roborock A01 devices on and off."""
|
||||
|
||||
entity_description: RoborockSwitchDescriptionA01
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RoborockDataUpdateCoordinatorA01,
|
||||
description: RoborockSwitchDescriptionA01,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.entity_description = description
|
||||
super().__init__(f"{description.key}_{coordinator.duid_slug}", coordinator)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch."""
|
||||
try:
|
||||
await self.coordinator.api.set_value( # type: ignore[attr-defined]
|
||||
self.entity_description.data_protocol, 0
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
except RoborockException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_options_failed",
|
||||
) from err
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
try:
|
||||
await self.coordinator.api.set_value( # type: ignore[attr-defined]
|
||||
self.entity_description.data_protocol, 1
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
except RoborockException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_options_failed",
|
||||
) from err
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if entity is on."""
|
||||
status = self.coordinator.data.get(self.entity_description.data_protocol)
|
||||
if status is None:
|
||||
return None
|
||||
return bool(status)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Support for Roborock vacuum class."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -13,11 +14,11 @@ from homeassistant.components.vacuum import (
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, MAP_SLEEP
|
||||
from .coordinator import (
|
||||
RoborockB01Q7UpdateCoordinator,
|
||||
RoborockConfigEntry,
|
||||
@@ -120,26 +121,6 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
|
||||
self._home_trait = coordinator.properties_api.home
|
||||
self._maps_trait = coordinator.properties_api.maps
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator.
|
||||
|
||||
Creates a repair issue when the vacuum reports different segments than
|
||||
what was available when the area mapping was last configured.
|
||||
"""
|
||||
super()._handle_coordinator_update()
|
||||
last_seen = self.last_seen_segments
|
||||
if last_seen is None:
|
||||
# No area mapping has been configured yet; nothing to check.
|
||||
return
|
||||
current_ids = {
|
||||
f"{map_flag}_{room.segment_id}"
|
||||
for map_flag, map_info in (self._home_trait.home_map_info or {}).items()
|
||||
for room in map_info.rooms
|
||||
}
|
||||
if current_ids != {seg.id for seg in last_seen}:
|
||||
self.async_create_segments_issue()
|
||||
|
||||
@property
|
||||
def fan_speed_list(self) -> list[str]:
|
||||
"""Get the list of available fan speeds."""
|
||||
@@ -211,7 +192,7 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
|
||||
return []
|
||||
return [
|
||||
Segment(
|
||||
id=f"{map_flag}_{room.segment_id}",
|
||||
id=f"{map_flag}:{room.segment_id}",
|
||||
name=room.name,
|
||||
group=map_info.name,
|
||||
)
|
||||
@@ -223,21 +204,51 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
|
||||
"""Clean the specified segments."""
|
||||
parsed: list[tuple[int, int]] = []
|
||||
for seg_id in segment_ids:
|
||||
map_flag_str, room_id_str = seg_id.split("_", maxsplit=1)
|
||||
parsed.append((int(map_flag_str), int(room_id_str)))
|
||||
# Segment id is mapflag:segment_id
|
||||
parts = seg_id.split(":")
|
||||
if len(parts) != 2:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="segment_id_parse_error",
|
||||
translation_placeholders={"segment_id": seg_id},
|
||||
)
|
||||
try:
|
||||
# We need to make sure both parts are ints.
|
||||
parsed.append((int(parts[0]), int(parts[1])))
|
||||
except ValueError as err:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="segment_id_parse_error",
|
||||
translation_placeholders={"segment_id": seg_id},
|
||||
) from err
|
||||
|
||||
# Segments from other maps are silently ignored; only segments
|
||||
# belonging to the currently active map are cleaned.
|
||||
current_map = self._maps_trait.current_map
|
||||
current_map_segments = [
|
||||
seg_id for map_flag, seg_id in parsed if map_flag == current_map
|
||||
]
|
||||
if not current_map_segments:
|
||||
return
|
||||
# Because segment_ids can overlap for each map,
|
||||
# we need to make sure that only one map is passed in.
|
||||
unique_map_flags = {map_flag for map_flag, _ in parsed}
|
||||
if len(unique_map_flags) > 1:
|
||||
map_flags_str = ", ".join(str(flag) for flag in sorted(unique_map_flags))
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="multiple_maps_in_clean",
|
||||
translation_placeholders={"map_flags": map_flags_str},
|
||||
)
|
||||
target_map_flag = next(iter(unique_map_flags))
|
||||
if self._maps_trait.current_map != target_map_flag:
|
||||
# If the user is attempting to clean an area on a map that is not selected, we should try to change.
|
||||
try:
|
||||
await self._maps_trait.set_current_map(target_map_flag)
|
||||
except RoborockException as err:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={"command": "load_multi_map"},
|
||||
) from err
|
||||
await asyncio.sleep(MAP_SLEEP)
|
||||
|
||||
# We can now confirm all segments are on our current map, so clean them all.
|
||||
await self.send(
|
||||
RoborockCommand.APP_SEGMENT_CLEAN,
|
||||
[{"segments": current_map_segments}],
|
||||
[{"segments": [seg_id for _, seg_id in parsed]}],
|
||||
)
|
||||
|
||||
async def async_send_command(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException
|
||||
@@ -242,9 +241,9 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
|
||||
|
||||
async def async_start_session(
|
||||
self,
|
||||
duration: timedelta = timedelta(minutes=120),
|
||||
duration: int = 120,
|
||||
target_temperature: int = 80,
|
||||
fan_duration: timedelta = timedelta(minutes=10),
|
||||
fan_duration: int = 10,
|
||||
) -> None:
|
||||
"""Start a sauna session with custom parameters."""
|
||||
if self.coordinator.data.door_open:
|
||||
@@ -255,15 +254,11 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
|
||||
|
||||
try:
|
||||
# Set all parameters before starting the session
|
||||
await self.coordinator.client.async_set_sauna_duration(
|
||||
int(duration.total_seconds() // 60)
|
||||
)
|
||||
await self.coordinator.client.async_set_sauna_duration(duration)
|
||||
await self.coordinator.client.async_set_target_temperature(
|
||||
target_temperature
|
||||
)
|
||||
await self.coordinator.client.async_set_fan_duration(
|
||||
int(fan_duration.total_seconds() // 60)
|
||||
)
|
||||
await self.coordinator.client.async_set_fan_duration(fan_duration)
|
||||
await self.coordinator.client.async_start_session()
|
||||
except SaunumException as err:
|
||||
raise HomeAssistantError(
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from pysaunum import MAX_DURATION, MAX_FAN_DURATION, MAX_TEMPERATURE, MIN_TEMPERATURE
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -29,22 +27,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_START_SESSION,
|
||||
entity_domain=CLIMATE_DOMAIN,
|
||||
schema={
|
||||
vol.Optional(ATTR_DURATION, default=timedelta(minutes=120)): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(
|
||||
min=timedelta(minutes=1),
|
||||
max=timedelta(minutes=MAX_DURATION),
|
||||
),
|
||||
vol.Optional(ATTR_DURATION, default=120): vol.All(
|
||||
cv.positive_int, vol.Range(min=1, max=MAX_DURATION)
|
||||
),
|
||||
vol.Optional(ATTR_TARGET_TEMPERATURE, default=80): vol.All(
|
||||
cv.positive_int, vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
|
||||
),
|
||||
vol.Optional(ATTR_FAN_DURATION, default=timedelta(minutes=10)): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(
|
||||
min=timedelta(minutes=1),
|
||||
max=timedelta(minutes=MAX_FAN_DURATION),
|
||||
),
|
||||
vol.Optional(ATTR_FAN_DURATION, default=10): vol.All(
|
||||
cv.positive_int, vol.Range(min=1, max=MAX_FAN_DURATION)
|
||||
),
|
||||
},
|
||||
func="async_start_session",
|
||||
|
||||
@@ -11,7 +11,6 @@ from simplipy import API
|
||||
from simplipy.errors import (
|
||||
EndpointUnavailableError,
|
||||
InvalidCredentialsError,
|
||||
RequestError,
|
||||
SimplipyError,
|
||||
WebsocketError,
|
||||
)
|
||||
@@ -47,9 +46,10 @@ from homeassistant.const import (
|
||||
CONF_CODE,
|
||||
CONF_TOKEN,
|
||||
CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
@@ -103,7 +103,6 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
WEBSOCKET_RECONNECT_RETRIES = 3
|
||||
WEBSOCKET_RETRY_DELAY = 2
|
||||
WEBSOCKET_LOOP_TASK_NAME = "simplisafe websocket task"
|
||||
|
||||
EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT"
|
||||
EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION"
|
||||
@@ -421,7 +420,8 @@ class SimpliSafe:
|
||||
self._api = api
|
||||
self._hass = hass
|
||||
self._system_notifications: dict[int, set[SystemNotification]] = {}
|
||||
self._websocket_task: asyncio.Task | None = None
|
||||
self._websocket_reconnect_retries: int = 0
|
||||
self._websocket_reconnect_task: asyncio.Task | None = None
|
||||
self.entry = entry
|
||||
self.initial_event_to_use: dict[int, dict[str, Any]] = {}
|
||||
self.subscription_data: dict[int, Any] = api.subscription_data
|
||||
@@ -467,69 +467,53 @@ class SimpliSafe:
|
||||
|
||||
self._system_notifications[system.system_id] = latest_notifications
|
||||
|
||||
@callback
|
||||
def _async_start_websocket_if_needed(self) -> None:
|
||||
"""Start the websocket loop task if it isn't already running."""
|
||||
task = self._websocket_task
|
||||
|
||||
if task and not task.done():
|
||||
return
|
||||
|
||||
LOGGER.debug("Starting websocket loop task")
|
||||
|
||||
self._websocket_task = self.entry.async_create_background_task(
|
||||
self._hass, self._async_websocket_loop(), WEBSOCKET_LOOP_TASK_NAME
|
||||
)
|
||||
|
||||
async def _async_websocket_loop(self) -> None:
|
||||
async def _async_start_websocket_loop(self) -> None:
|
||||
"""Start a websocket reconnection loop."""
|
||||
assert self._api.websocket
|
||||
|
||||
retries = 0
|
||||
while True:
|
||||
try:
|
||||
await self._api.websocket.async_connect()
|
||||
await self._api.websocket.async_listen()
|
||||
except asyncio.CancelledError:
|
||||
await self._api.websocket.async_disconnect()
|
||||
raise
|
||||
except WebsocketError as err:
|
||||
retries += 1
|
||||
delay = WEBSOCKET_RETRY_DELAY * (2 ** (retries - 1))
|
||||
LOGGER.debug(
|
||||
"Websocket error (%s/%s): %s; retrying in %s seconds",
|
||||
retries,
|
||||
WEBSOCKET_RECONNECT_RETRIES,
|
||||
err,
|
||||
delay,
|
||||
)
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
if retries >= WEBSOCKET_RECONNECT_RETRIES:
|
||||
LOGGER.error(
|
||||
"Websocket connection failed, task exiting (%s/%s): %s",
|
||||
retries,
|
||||
WEBSOCKET_RECONNECT_RETRIES,
|
||||
err,
|
||||
)
|
||||
return
|
||||
except Exception as err: # noqa: BLE001
|
||||
# unexpected errors → log and stop
|
||||
LOGGER.exception("Unexpected error in websocket loop: %s", err)
|
||||
return
|
||||
|
||||
async def _async_cancel_websocket_loop(self) -> None:
|
||||
"""Cancel the websocket loop task, if running."""
|
||||
task = self._websocket_task
|
||||
if not task:
|
||||
return
|
||||
|
||||
self._websocket_task = None
|
||||
task.cancel()
|
||||
self._websocket_reconnect_retries += 1
|
||||
|
||||
try:
|
||||
await task
|
||||
await self._api.websocket.async_connect()
|
||||
await self._api.websocket.async_listen()
|
||||
except asyncio.CancelledError:
|
||||
LOGGER.debug("Websocket loop task cancelled")
|
||||
LOGGER.debug("Request to cancel websocket loop received")
|
||||
raise
|
||||
except WebsocketError as err:
|
||||
LOGGER.error("Failed to connect to websocket: %s", err)
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.error("Unknown exception while connecting to websocket: %s", err)
|
||||
else:
|
||||
self._websocket_reconnect_retries = 0
|
||||
|
||||
if self._websocket_reconnect_retries >= WEBSOCKET_RECONNECT_RETRIES:
|
||||
LOGGER.error("Max websocket connection retries exceeded")
|
||||
return
|
||||
|
||||
delay = WEBSOCKET_RETRY_DELAY * (2 ** (self._websocket_reconnect_retries - 1))
|
||||
LOGGER.info(
|
||||
"Retrying websocket connection in %s seconds (attempt %s/%s)",
|
||||
delay,
|
||||
self._websocket_reconnect_retries,
|
||||
WEBSOCKET_RECONNECT_RETRIES,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
self._websocket_reconnect_task = self._hass.async_create_task(
|
||||
self._async_start_websocket_loop()
|
||||
)
|
||||
|
||||
async def _async_cancel_websocket_loop(self) -> None:
|
||||
"""Stop any existing websocket reconnection loop."""
|
||||
if self._websocket_reconnect_task:
|
||||
self._websocket_reconnect_task.cancel()
|
||||
try:
|
||||
await self._websocket_reconnect_task
|
||||
except asyncio.CancelledError:
|
||||
LOGGER.debug("Websocket reconnection task successfully canceled")
|
||||
self._websocket_reconnect_task = None
|
||||
|
||||
assert self._api.websocket
|
||||
await self._api.websocket.async_disconnect()
|
||||
|
||||
@callback
|
||||
def _async_websocket_on_event(self, event: WebsocketEvent) -> None:
|
||||
@@ -569,7 +553,20 @@ class SimpliSafe:
|
||||
assert self._api.websocket
|
||||
|
||||
self._api.websocket.add_event_callback(self._async_websocket_on_event)
|
||||
self._async_start_websocket_if_needed()
|
||||
self._websocket_reconnect_task = asyncio.create_task(
|
||||
self._async_start_websocket_loop()
|
||||
)
|
||||
|
||||
async def async_websocket_disconnect_listener(_: Event) -> None:
|
||||
"""Define an event handler to disconnect from the websocket."""
|
||||
assert self._api.websocket
|
||||
await self._async_cancel_websocket_loop()
|
||||
|
||||
self.entry.async_on_unload(
|
||||
self._hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, async_websocket_disconnect_listener
|
||||
)
|
||||
)
|
||||
|
||||
self.systems = await self._api.async_get_systems()
|
||||
for system in self.systems.values():
|
||||
@@ -613,7 +610,9 @@ class SimpliSafe:
|
||||
# Open a new websocket connection with the fresh token:
|
||||
assert self._api.websocket
|
||||
await self._async_cancel_websocket_loop()
|
||||
self._async_start_websocket_if_needed()
|
||||
self._websocket_reconnect_task = self._hass.async_create_task(
|
||||
self._async_start_websocket_loop()
|
||||
)
|
||||
|
||||
self.entry.async_on_unload(
|
||||
self._api.add_refresh_token_callback(async_handle_refresh_token)
|
||||
@@ -626,37 +625,22 @@ class SimpliSafe:
|
||||
"""Get updated data from SimpliSafe."""
|
||||
|
||||
async def async_update_system(system: SystemType) -> None:
|
||||
"""Update a single system and process notifications."""
|
||||
"""Update a system."""
|
||||
await system.async_update(cached=system.version != 3)
|
||||
self._async_process_new_notifications(system)
|
||||
|
||||
tasks = [async_update_system(system) for system in self.systems.values()]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
try:
|
||||
# Gather all system updates; exceptions will propagate
|
||||
await asyncio.gather(*tasks)
|
||||
except InvalidCredentialsError as err:
|
||||
# Stop websocket immediately on auth failure
|
||||
if self._websocket_task:
|
||||
LOGGER.debug("Cancelling websocket loop due to invalid credentials")
|
||||
await self._async_cancel_websocket_loop()
|
||||
# Signal HA that credentials are invalid; user intervention is required
|
||||
raise ConfigEntryAuthFailed("Invalid credentials") from err
|
||||
except RequestError as err:
|
||||
# Cloud-level request errors: wrap aiohttp errors
|
||||
if self._websocket_task:
|
||||
LOGGER.debug("Cancelling websocket loop due to request error")
|
||||
await self._async_cancel_websocket_loop()
|
||||
raise UpdateFailed(
|
||||
f"Request error while updating all systems: {err}"
|
||||
) from err
|
||||
except EndpointUnavailableError as err:
|
||||
# Currently not raised by the API; included for future-proofing.
|
||||
# Informational per-system (e.g., user plan restrictions)
|
||||
LOGGER.debug("Endpoint unavailable: %s", err)
|
||||
except SimplipyError as err:
|
||||
# Any other SimplipyError not caught per-system
|
||||
raise UpdateFailed(f"SimpliSafe error while updating: {err}") from err
|
||||
else:
|
||||
# Successful update, try to restart websocket if necessary
|
||||
self._async_start_websocket_if_needed()
|
||||
for result in results:
|
||||
if isinstance(result, InvalidCredentialsError):
|
||||
raise ConfigEntryAuthFailed("Invalid credentials") from result
|
||||
|
||||
if isinstance(result, EndpointUnavailableError):
|
||||
# In case the user attempts an action not allowed in their current plan,
|
||||
# we merely log that message at INFO level (so the user is aware,
|
||||
# but not spammed with ERROR messages that they cannot change):
|
||||
LOGGER.debug(result)
|
||||
|
||||
if isinstance(result, SimplipyError):
|
||||
raise UpdateFailed(f"SimpliSafe error while updating: {result}")
|
||||
|
||||
@@ -108,7 +108,7 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity):
|
||||
"""Update the entity when new data comes from the websocket."""
|
||||
assert event.event_type
|
||||
|
||||
if (state := STATE_MAP_FROM_WEBSOCKET_EVENT.get(event.event_type)) is not None:
|
||||
if state := STATE_MAP_FROM_WEBSOCKET_EVENT.get(event.event_type) is not None:
|
||||
self._attr_is_locked = state
|
||||
self.async_reset_error_count()
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Common base for entities."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pysmarlaapi import Federwiege
|
||||
@@ -11,8 +10,6 @@ from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
|
||||
from .const import DEVICE_MODEL_NAME, DOMAIN, MANUFACTURER_NAME
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SmarlaEntityDescription(EntityDescription):
|
||||
@@ -33,7 +30,6 @@ class SmarlaBaseEntity(Entity):
|
||||
def __init__(self, federwiege: Federwiege, desc: SmarlaEntityDescription) -> None:
|
||||
"""Initialise the entity."""
|
||||
self.entity_description = desc
|
||||
self._federwiege = federwiege
|
||||
self._property = federwiege.get_property(desc.service, desc.property)
|
||||
self._attr_unique_id = f"{federwiege.serial_number}-{desc.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
@@ -43,35 +39,15 @@ class SmarlaBaseEntity(Entity):
|
||||
manufacturer=MANUFACTURER_NAME,
|
||||
serial_number=federwiege.serial_number,
|
||||
)
|
||||
self._unavailable_logged = False
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self._federwiege.available
|
||||
|
||||
async def on_availability_change(self, available: bool) -> None:
|
||||
"""Handle availability changes."""
|
||||
if not self.available and not self._unavailable_logged:
|
||||
_LOGGER.info("Entity %s is unavailable", self.entity_id)
|
||||
self._unavailable_logged = True
|
||||
elif self.available and self._unavailable_logged:
|
||||
_LOGGER.info("Entity %s is back online", self.entity_id)
|
||||
self._unavailable_logged = False
|
||||
|
||||
# Notify ha that state changed
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def on_change(self, value: Any) -> None:
|
||||
async def on_change(self, value: Any):
|
||||
"""Notify ha when state changes."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when this Entity has been added to HA."""
|
||||
await self._federwiege.add_listener(self.on_availability_change)
|
||||
await self._property.add_listener(self.on_change)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Entity being removed from hass."""
|
||||
await self._property.remove_listener(self.on_change)
|
||||
await self._federwiege.remove_listener(self.on_availability_change)
|
||||
|
||||
@@ -24,9 +24,9 @@ rules:
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import cast
|
||||
|
||||
from surepy.entities import SurepyEntity
|
||||
from surepy.entities.devices import Felaqua as SurepyFelaqua
|
||||
from surepy.entities.pet import Pet as SurepyPet
|
||||
from surepy.enums import EntityType
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
@@ -42,9 +41,6 @@ async def async_setup_entry(
|
||||
|
||||
if surepy_entity.type == EntityType.FELAQUA:
|
||||
entities.append(Felaqua(surepy_entity.id, coordinator))
|
||||
if surepy_entity.type == EntityType.PET:
|
||||
entities.append(PetLastSeenFlapDevice(surepy_entity.id, coordinator))
|
||||
entities.append(PetLastSeenUser(surepy_entity.id, coordinator))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -112,55 +108,3 @@ class Felaqua(SurePetcareEntity, SensorEntity):
|
||||
"""Update the state."""
|
||||
surepy_entity = cast(SurepyFelaqua, surepy_entity)
|
||||
self._attr_native_value = surepy_entity.water_remaining
|
||||
|
||||
|
||||
class PetLastSeenFlapDevice(SurePetcareEntity, SensorEntity):
|
||||
"""Sensor for the last flap device id used by the pet.
|
||||
|
||||
Note: Will be unknown if the last status is not from a flap update.
|
||||
"""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def __init__(
|
||||
self, surepetcare_id: int, coordinator: SurePetcareDataCoordinator
|
||||
) -> None:
|
||||
"""Initialize last seen flap device id sensor."""
|
||||
super().__init__(surepetcare_id, coordinator)
|
||||
|
||||
self._attr_name = f"{self._device_name} Last seen flap device id"
|
||||
self._attr_unique_id = f"{self._device_id}-last_seen_flap_device"
|
||||
|
||||
@callback
|
||||
def _update_attr(self, surepy_entity: SurepyEntity) -> None:
|
||||
surepy_entity = cast(SurepyPet, surepy_entity)
|
||||
position = surepy_entity._data.get("position", {}) # noqa: SLF001
|
||||
device_id = position.get("device_id")
|
||||
self._attr_native_value = str(device_id) if device_id is not None else None
|
||||
|
||||
|
||||
class PetLastSeenUser(SurePetcareEntity, SensorEntity):
|
||||
"""Sensor for the last user id that manually changed the pet location.
|
||||
|
||||
Note: Will be unknown if the last status is not from a manual update.
|
||||
"""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def __init__(
|
||||
self, surepetcare_id: int, coordinator: SurePetcareDataCoordinator
|
||||
) -> None:
|
||||
"""Initialize last seen user id sensor."""
|
||||
super().__init__(surepetcare_id, coordinator)
|
||||
|
||||
self._attr_name = f"{self._device_name} Last seen user id"
|
||||
self._attr_unique_id = f"{self._device_id}-last_seen_user"
|
||||
|
||||
@callback
|
||||
def _update_attr(self, surepy_entity: SurepyEntity) -> None:
|
||||
surepy_entity = cast(SurepyPet, surepy_entity)
|
||||
position = surepy_entity._data.get("position", {}) # noqa: SLF001
|
||||
user_id = position.get("user_id")
|
||||
self._attr_native_value = str(user_id) if user_id is not None else None
|
||||
|
||||
@@ -6,4 +6,4 @@ from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "systemnexa2"
|
||||
MANUFACTURER = "NEXA"
|
||||
PLATFORMS: Final = [Platform.LIGHT, Platform.SWITCH]
|
||||
PLATFORMS: Final = [Platform.SWITCH]
|
||||
|
||||
@@ -156,12 +156,6 @@ class SystemNexa2DataUpdateCoordinator(DataUpdateCoordinator[SystemNexa2Data]):
|
||||
"""Toggle the device."""
|
||||
await self._async_sn2_call_with_error_handling(self.device.toggle())
|
||||
|
||||
async def async_set_brightness(self, value: float) -> None:
|
||||
"""Set the brightness of the device (0.0 to 1.0)."""
|
||||
await self._async_sn2_call_with_error_handling(
|
||||
self.device.set_brightness(value)
|
||||
)
|
||||
|
||||
async def async_setting_enable(self, setting: OnOffSetting) -> None:
|
||||
"""Enable a device setting."""
|
||||
await self._async_sn2_call_with_error_handling(setting.enable(self.device))
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Light entity for the SystemNexa2 integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import SystemNexa2ConfigEntry, SystemNexa2DataUpdateCoordinator
|
||||
from .entity import SystemNexa2Entity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SystemNexa2ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up lights based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Only add light entity for dimmable devices
|
||||
if coordinator.data.info_data.dimmable:
|
||||
async_add_entities([SystemNexa2Light(coordinator)])
|
||||
|
||||
|
||||
class SystemNexa2Light(SystemNexa2Entity, LightEntity):
|
||||
"""Representation of a dimmable SystemNexa2 light."""
|
||||
|
||||
_attr_translation_key = "light"
|
||||
_attr_color_mode = ColorMode.BRIGHTNESS
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: SystemNexa2DataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the light."""
|
||||
super().__init__(
|
||||
coordinator=coordinator,
|
||||
key="light",
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the light."""
|
||||
# Check if we're setting brightness
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
# Convert HomeAssistant brightness (0-255) to device brightness (0-1.0)
|
||||
value = brightness / 255
|
||||
await self.coordinator.async_set_brightness(value)
|
||||
else:
|
||||
await self.coordinator.async_turn_on()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
await self.coordinator.async_turn_off()
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the light is on."""
|
||||
if self.coordinator.data.state is None:
|
||||
return None
|
||||
# Consider the light on if brightness is greater than 0
|
||||
return self.coordinator.data.state > 0
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the brightness of the light (0-255)."""
|
||||
if self.coordinator.data.state is None:
|
||||
return None
|
||||
# Convert device brightness (0-1.0) to HomeAssistant brightness (0-255)
|
||||
return max(0, min(255, round(self.coordinator.data.state * 255)))
|
||||
@@ -31,11 +31,6 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"light": {
|
||||
"light": {
|
||||
"name": "Light"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"433mhz": {
|
||||
"name": "433 MHz"
|
||||
@@ -50,7 +45,7 @@
|
||||
"name": "Physical button"
|
||||
},
|
||||
"relay_1": {
|
||||
"name": "Relay"
|
||||
"name": "Relay 1"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user