mirror of
https://github.com/home-assistant/core.git
synced 2026-05-03 19:41:15 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a3fb703e7 | |||
| d2dc752328 | |||
| 9c5d2ecce6 |
@@ -13,7 +13,6 @@ core: &core
|
||||
|
||||
# Our base platforms, that are used by other integrations
|
||||
base_platforms: &base_platforms
|
||||
- homeassistant/components/ai_task/**
|
||||
- homeassistant/components/air_quality/**
|
||||
- homeassistant/components/alarm_control_panel/**
|
||||
- homeassistant/components/assist_satellite/**
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"charliermarsh.ruff",
|
||||
"ms-python.pylint",
|
||||
"ms-python.vscode-pylance",
|
||||
"visualstudioexptteam.vscodeintellicode",
|
||||
"redhat.vscode-yaml",
|
||||
"esbenp.prettier-vscode",
|
||||
"GitHub.vscode-pull-request-github",
|
||||
|
||||
+92
-152
@@ -14,9 +14,6 @@ env:
|
||||
PIP_TIMEOUT: 60
|
||||
UV_HTTP_TIMEOUT: 60
|
||||
UV_SYSTEM_PYTHON: "true"
|
||||
# Base image version from https://github.com/home-assistant/docker
|
||||
BASE_IMAGE_VERSION: "2025.11.3"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
jobs:
|
||||
init:
|
||||
@@ -24,13 +21,15 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
architectures: ${{ steps.info.outputs.architectures }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
channel: ${{ steps.version.outputs.channel }}
|
||||
publish: ${{ steps.version.outputs.publish }}
|
||||
architectures: ${{ env.ARCHITECTURES }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
@@ -80,7 +79,7 @@ jobs:
|
||||
name: Build ${{ matrix.arch }} base core image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -89,14 +88,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-latest
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
@@ -190,60 +184,16 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- &install_cosign
|
||||
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@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Build variables
|
||||
id: vars
|
||||
shell: bash
|
||||
run: |
|
||||
echo "base_image=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ env.BASE_IMAGE_VERSION }}" >> "$GITHUB_OUTPUT"
|
||||
echo "cache_image=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify base image signature
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
|
||||
"${{ steps.vars.outputs.base_image }}"
|
||||
|
||||
- name: Verify cache image signature
|
||||
id: cache
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
|
||||
"${{ steps.vars.outputs.cache_image }}"
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build base image
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
with:
|
||||
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 }}
|
||||
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
|
||||
run: |
|
||||
cosign sign --yes "ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}@${{ steps.build.outputs.digest }}"
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
--${{ matrix.arch }} \
|
||||
--cosign \
|
||||
--target /data \
|
||||
--generic ${{ needs.init.outputs.version }}
|
||||
|
||||
build_machine:
|
||||
name: Build ${{ matrix.machine }} machine core image
|
||||
@@ -273,7 +223,7 @@ jobs:
|
||||
- green
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set build additional args
|
||||
run: |
|
||||
@@ -295,7 +245,7 @@ jobs:
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2025.11.0
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -311,7 +261,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master
|
||||
@@ -354,7 +304,13 @@ jobs:
|
||||
matrix:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- *install_cosign
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.2.3"
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -364,104 +320,88 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: matrix.registry == 'ghcr.io/home-assistant'
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify architecture image signatures
|
||||
- name: Build Meta Image
|
||||
shell: bash
|
||||
run: |
|
||||
ARCHS=$(echo '${{ needs.init.outputs.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:${{ needs.init.outputs.version }}"
|
||||
done
|
||||
echo "✓ All images verified successfully"
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
|
||||
# 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') }}
|
||||
function create_manifest() {
|
||||
local tag_l=${1}
|
||||
local tag_r=${2}
|
||||
local registry=${{ matrix.registry }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.7.1
|
||||
docker manifest create "${registry}/home-assistant:${tag_l}" \
|
||||
"${registry}/amd64-homeassistant:${tag_r}" \
|
||||
"${registry}/aarch64-homeassistant:${tag_r}"
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
shell: bash
|
||||
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 '${{ needs.init.outputs.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:${{ needs.init.outputs.version }}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.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:${{ needs.init.outputs.version }}"
|
||||
done
|
||||
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
|
||||
"${registry}/amd64-homeassistant:${tag_r}" \
|
||||
--os linux --arch amd64
|
||||
|
||||
- name: Create and push multi-arch manifests
|
||||
shell: bash
|
||||
run: |
|
||||
# Build list of architecture images dynamically
|
||||
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
|
||||
ARCH_IMAGES=()
|
||||
for arch in $ARCHS; do
|
||||
ARCH_IMAGES+=("${{ matrix.registry }}/${arch}-homeassistant:${{ needs.init.outputs.version }}")
|
||||
done
|
||||
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
|
||||
"${registry}/aarch64-homeassistant:${tag_r}" \
|
||||
--os linux --arch arm64 --variant=v8
|
||||
|
||||
# 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 <<< "${{ steps.meta.outputs.tags }}"
|
||||
for tag in "${TAGS[@]}"; do
|
||||
TAG_ARGS+=("--tag" "${tag}")
|
||||
done
|
||||
docker manifest push --purge "${registry}/home-assistant:${tag_l}"
|
||||
cosign sign --yes "${registry}/home-assistant:${tag_l}"
|
||||
}
|
||||
|
||||
# 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[@]}"
|
||||
function validate_image() {
|
||||
local image=${1}
|
||||
if ! cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp https://github.com/home-assistant/core/.* "${image}"; then
|
||||
echo "Invalid signature!"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 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
|
||||
function push_dockerhub() {
|
||||
local image=${1}
|
||||
local tag=${2}
|
||||
|
||||
echo "All manifests created and signed successfully"
|
||||
docker tag "ghcr.io/home-assistant/${image}:${tag}" "docker.io/homeassistant/${image}:${tag}"
|
||||
docker push "docker.io/homeassistant/${image}:${tag}"
|
||||
cosign sign --yes "docker.io/homeassistant/${image}:${tag}"
|
||||
}
|
||||
|
||||
# Pull images from github container registry and verify signature
|
||||
docker pull "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
|
||||
validate_image "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
|
||||
if [[ "${{ matrix.registry }}" == "docker.io/homeassistant" ]]; then
|
||||
# Upload images to dockerhub
|
||||
push_dockerhub "amd64-homeassistant" "${{ needs.init.outputs.version }}"
|
||||
push_dockerhub "aarch64-homeassistant" "${{ needs.init.outputs.version }}"
|
||||
fi
|
||||
|
||||
# Create version tag
|
||||
create_manifest "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}"
|
||||
|
||||
# Create general tags
|
||||
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
|
||||
create_manifest "dev" "${{ needs.init.outputs.version }}"
|
||||
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
|
||||
create_manifest "beta" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "rc" "${{ needs.init.outputs.version }}"
|
||||
else
|
||||
create_manifest "stable" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "latest" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "beta" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "rc" "${{ needs.init.outputs.version }}"
|
||||
|
||||
# Create series version tag (e.g. 2021.6)
|
||||
v="${{ needs.init.outputs.version }}"
|
||||
create_manifest "${v%.*}" "${{ needs.init.outputs.version }}"
|
||||
fi
|
||||
|
||||
build_python:
|
||||
name: Build PyPi package
|
||||
@@ -474,7 +414,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
@@ -519,7 +459,7 @@ jobs:
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
|
||||
@@ -40,9 +40,9 @@ env:
|
||||
CACHE_VERSION: 2
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.1"
|
||||
DEFAULT_PYTHON: "3.13.11"
|
||||
ALL_PYTHON_VERSIONS: "['3.13.11', '3.14.2']"
|
||||
HA_SHORT_VERSION: "2025.12"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13', '3.14']"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
# 10.6 is the current long-term-support
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Check out code from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
- name: Generate partial Python venv restore key
|
||||
id: generate_python_cache_key
|
||||
run: |
|
||||
@@ -1188,7 +1188,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1313,7 +1313,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -21,14 +21,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
|
||||
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
|
||||
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
uses: actions/ai-inference@5022b33bc1431add9b2831934daf8147a2ad9331 # v2.0.2
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
uses: actions/ai-inference@5022b33bc1431add9b2831934daf8147a2ad9331 # v2.0.2
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
|
||||
@@ -28,10 +28,12 @@ jobs:
|
||||
name: Initialize wheels builder
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
architectures: ${{ steps.info.outputs.architectures }}
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
@@ -48,6 +50,10 @@ jobs:
|
||||
pip install "$(grep '^uv' < requirements.txt)"
|
||||
uv pip install -r requirements.txt
|
||||
|
||||
- name: Get information
|
||||
id: info
|
||||
uses: home-assistant/actions/helpers/info@master
|
||||
|
||||
- name: Create requirements_diff file
|
||||
run: |
|
||||
if [[ ${{ github.event_name }} =~ (schedule|workflow_dispatch) ]]; then
|
||||
@@ -108,10 +114,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix: &matrix-build
|
||||
abi: ["cp313", "cp314"]
|
||||
arch: ["amd64", "aarch64"]
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-latest
|
||||
- os: ubuntu-latest
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
@@ -135,8 +140,9 @@ jobs:
|
||||
sed -i "/uv/d" requirements.txt
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
# home-assistant/wheels doesn't support sha pinning
|
||||
- name: Build wheels
|
||||
uses: &home-assistant-wheels home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
uses: &home-assistant-wheels home-assistant/wheels@2025.10.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
@@ -177,6 +183,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements.txt
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
# home-assistant/wheels doesn't support sha pinning
|
||||
- name: Build wheels
|
||||
uses: *home-assistant-wheels
|
||||
with:
|
||||
|
||||
+1
-1
@@ -120,6 +120,7 @@ homeassistant.components.blueprint.*
|
||||
homeassistant.components.bluesound.*
|
||||
homeassistant.components.bluetooth.*
|
||||
homeassistant.components.bluetooth_adapters.*
|
||||
homeassistant.components.bluetooth_tracker.*
|
||||
homeassistant.components.bmw_connected_drive.*
|
||||
homeassistant.components.bond.*
|
||||
homeassistant.components.bosch_alarm.*
|
||||
@@ -187,7 +188,6 @@ homeassistant.components.elkm1.*
|
||||
homeassistant.components.emulated_hue.*
|
||||
homeassistant.components.energenie_power_sockets.*
|
||||
homeassistant.components.energy.*
|
||||
homeassistant.components.energyid.*
|
||||
homeassistant.components.energyzero.*
|
||||
homeassistant.components.enigma2.*
|
||||
homeassistant.components.enphase_envoy.*
|
||||
|
||||
Generated
+5
-26
@@ -73,8 +73,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/airobot/ @mettolen
|
||||
/homeassistant/components/airos/ @CoMPaTech
|
||||
/tests/components/airos/ @CoMPaTech
|
||||
/homeassistant/components/airpatrol/ @antondalgren
|
||||
/tests/components/airpatrol/ @antondalgren
|
||||
/homeassistant/components/airq/ @Sibgatulin @dl2080
|
||||
/tests/components/airq/ @Sibgatulin @dl2080
|
||||
/homeassistant/components/airthings/ @danielhiversen @LaStrada
|
||||
@@ -123,8 +121,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/androidtv/ @JeffLIrion @ollo69
|
||||
/homeassistant/components/androidtv_remote/ @tronikos @Drafteed
|
||||
/tests/components/androidtv_remote/ @tronikos @Drafteed
|
||||
/homeassistant/components/anglian_water/ @pantherale0
|
||||
/tests/components/anglian_water/ @pantherale0
|
||||
/homeassistant/components/anova/ @Lash-L
|
||||
/tests/components/anova/ @Lash-L
|
||||
/homeassistant/components/anthemav/ @hyralex
|
||||
@@ -187,8 +183,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/automation/ @home-assistant/core
|
||||
/tests/components/automation/ @home-assistant/core
|
||||
/homeassistant/components/avea/ @pattyland
|
||||
/homeassistant/components/awair/ @ahayworth @ricohageman
|
||||
/tests/components/awair/ @ahayworth @ricohageman
|
||||
/homeassistant/components/awair/ @ahayworth @danielsjf
|
||||
/tests/components/awair/ @ahayworth @danielsjf
|
||||
/homeassistant/components/aws_s3/ @tomasbedrich
|
||||
/tests/components/aws_s3/ @tomasbedrich
|
||||
/homeassistant/components/axis/ @Kane610
|
||||
@@ -420,8 +416,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/efergy/ @tkdrob
|
||||
/tests/components/efergy/ @tkdrob
|
||||
/homeassistant/components/egardia/ @jeroenterheerdt
|
||||
/homeassistant/components/egauge/ @neggert
|
||||
/tests/components/egauge/ @neggert
|
||||
/homeassistant/components/eheimdigital/ @autinerd
|
||||
/tests/components/eheimdigital/ @autinerd
|
||||
/homeassistant/components/ekeybionyx/ @richardpolzer
|
||||
@@ -456,15 +450,13 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/energenie_power_sockets/ @gnumpi
|
||||
/homeassistant/components/energy/ @home-assistant/core
|
||||
/tests/components/energy/ @home-assistant/core
|
||||
/homeassistant/components/energyid/ @JrtPec @Molier
|
||||
/tests/components/energyid/ @JrtPec @Molier
|
||||
/homeassistant/components/energyzero/ @klaasnicolaas
|
||||
/tests/components/energyzero/ @klaasnicolaas
|
||||
/homeassistant/components/enigma2/ @autinerd
|
||||
/tests/components/enigma2/ @autinerd
|
||||
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom
|
||||
/homeassistant/components/entur_public_transport/ @hfurubotten
|
||||
/homeassistant/components/environment_canada/ @gwww @michaeldavie
|
||||
/tests/components/environment_canada/ @gwww @michaeldavie
|
||||
/homeassistant/components/ephember/ @ttroy50 @roberty99
|
||||
@@ -480,8 +472,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/escea/ @lazdavila
|
||||
/homeassistant/components/esphome/ @jesserockz @kbx81 @bdraco
|
||||
/tests/components/esphome/ @jesserockz @kbx81 @bdraco
|
||||
/homeassistant/components/essent/ @jaapp
|
||||
/tests/components/essent/ @jaapp
|
||||
/homeassistant/components/eufylife_ble/ @bdr99
|
||||
/tests/components/eufylife_ble/ @bdr99
|
||||
/homeassistant/components/event/ @home-assistant/core
|
||||
@@ -543,8 +533,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/freebox/ @hacf-fr @Quentame
|
||||
/homeassistant/components/freedompro/ @stefano055415
|
||||
/tests/components/freedompro/ @stefano055415
|
||||
/homeassistant/components/fressnapf_tracker/ @eifinger
|
||||
/tests/components/fressnapf_tracker/ @eifinger
|
||||
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
/homeassistant/components/fritzbox/ @mib1185 @flabbamann
|
||||
@@ -575,8 +563,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/generic_hygrostat/ @Shulyaka
|
||||
/homeassistant/components/geniushub/ @manzanotti
|
||||
/tests/components/geniushub/ @manzanotti
|
||||
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/homeassistant/components/geo_json_events/ @exxamalte
|
||||
/tests/components/geo_json_events/ @exxamalte
|
||||
/homeassistant/components/geo_location/ @home-assistant/core
|
||||
@@ -605,8 +591,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/goodwe/ @mletenay @starkillerOG
|
||||
/homeassistant/components/google/ @allenporter
|
||||
/tests/components/google/ @allenporter
|
||||
/homeassistant/components/google_air_quality/ @Thomas55555
|
||||
/tests/components/google_air_quality/ @Thomas55555
|
||||
/homeassistant/components/google_assistant/ @home-assistant/cloud
|
||||
/tests/components/google_assistant/ @home-assistant/cloud
|
||||
/homeassistant/components/google_assistant_sdk/ @tronikos
|
||||
@@ -716,8 +700,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/huawei_lte/ @scop @fphammerle
|
||||
/homeassistant/components/hue/ @marcelveldt
|
||||
/tests/components/hue/ @marcelveldt
|
||||
/homeassistant/components/hue_ble/ @flip-dots
|
||||
/tests/components/hue_ble/ @flip-dots
|
||||
/homeassistant/components/huisbaasje/ @dennisschroer
|
||||
/tests/components/huisbaasje/ @dennisschroer
|
||||
/homeassistant/components/humidifier/ @home-assistant/core @Shulyaka
|
||||
@@ -1362,8 +1344,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ring/ @sdb9696
|
||||
/homeassistant/components/risco/ @OnFreund
|
||||
/tests/components/risco/ @OnFreund
|
||||
/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm
|
||||
/tests/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm
|
||||
/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck
|
||||
/tests/components/rituals_perfume_genie/ @milanmeu @frenck
|
||||
/homeassistant/components/rmvtransport/ @cgtobi
|
||||
/tests/components/rmvtransport/ @cgtobi
|
||||
/homeassistant/components/roborock/ @Lash-L @allenporter
|
||||
@@ -1769,7 +1751,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
/tests/components/vilfo/ @ManneW
|
||||
/homeassistant/components/vivotek/ @HarlemSquirrel
|
||||
/tests/components/vivotek/ @HarlemSquirrel
|
||||
/homeassistant/components/vizio/ @raman325
|
||||
/tests/components/vizio/ @raman325
|
||||
/homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare
|
||||
@@ -1809,8 +1790,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/weatherflow_cloud/ @jeeftor
|
||||
/homeassistant/components/weatherkit/ @tjhorner
|
||||
/tests/components/weatherkit/ @tjhorner
|
||||
/homeassistant/components/web_rtc/ @home-assistant/core
|
||||
/tests/components/web_rtc/ @home-assistant/core
|
||||
/homeassistant/components/webdav/ @jpbede
|
||||
/tests/components/webdav/ @jpbede
|
||||
/homeassistant/components/webhook/ @home-assistant/core
|
||||
|
||||
Generated
+2
-10
@@ -4,22 +4,14 @@
|
||||
ARG BUILD_FROM
|
||||
FROM ${BUILD_FROM}
|
||||
|
||||
LABEL \
|
||||
io.hass.type="core" \
|
||||
org.opencontainers.image.authors="The Home Assistant Authors" \
|
||||
org.opencontainers.image.description="Open-source home automation platform running on Python 3" \
|
||||
org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \
|
||||
org.opencontainers.image.licenses="Apache-2.0" \
|
||||
org.opencontainers.image.source="https://github.com/home-assistant/core" \
|
||||
org.opencontainers.image.title="Home Assistant" \
|
||||
org.opencontainers.image.url="https://www.home-assistant.io/"
|
||||
|
||||
# Synchronize with homeassistant/core.py:async_stop
|
||||
ENV \
|
||||
S6_SERVICES_GRACETIME=240000 \
|
||||
UV_SYSTEM_PYTHON=true \
|
||||
UV_NO_CACHE=true
|
||||
|
||||
ARG QEMU_CPU
|
||||
|
||||
# Home Assistant S6-Overlay
|
||||
COPY rootfs /
|
||||
|
||||
|
||||
+11
-8
@@ -35,22 +35,25 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
USER vscode
|
||||
|
||||
COPY .python-version ./
|
||||
RUN uv python install
|
||||
|
||||
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
|
||||
RUN --mount=type=bind,source=.python-version,target=.python-version \
|
||||
uv python install \
|
||||
&& uv venv $VIRTUAL_ENV
|
||||
RUN uv venv $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# Setup hass-release
|
||||
RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \
|
||||
&& uv pip install -e ~/hass-release/
|
||||
|
||||
# Install Python dependencies from requirements
|
||||
RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \
|
||||
--mount=type=bind,source=homeassistant/package_constraints.txt,target=homeassistant/package_constraints.txt \
|
||||
--mount=type=bind,source=requirements_test.txt,target=requirements_test.txt \
|
||||
--mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
COPY requirements.txt ./
|
||||
COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
|
||||
RUN uv pip install -r requirements.txt
|
||||
COPY requirements_test.txt requirements_test_pre_commit.txt ./
|
||||
RUN uv pip install -r requirements_test.txt
|
||||
|
||||
WORKDIR /workspaces
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.11.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.11.0
|
||||
cosign:
|
||||
base_identity: https://github.com/home-assistant/docker/.*
|
||||
identity: https://github.com/home-assistant/core/.*
|
||||
labels:
|
||||
io.hass.type: core
|
||||
org.opencontainers.image.title: Home Assistant
|
||||
org.opencontainers.image.description: Open-source home automation platform running on Python 3
|
||||
org.opencontainers.image.source: https://github.com/home-assistant/core
|
||||
org.opencontainers.image.authors: The Home Assistant Authors
|
||||
org.opencontainers.image.url: https://www.home-assistant.io/
|
||||
org.opencontainers.image.documentation: https://www.home-assistant.io/docs/
|
||||
org.opencontainers.image.licenses: Apache-2.0
|
||||
@@ -7,7 +7,6 @@ from typing import Any, Final
|
||||
from homeassistant.const import (
|
||||
EVENT_COMPONENT_LOADED,
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
EVENT_LABS_UPDATED,
|
||||
EVENT_LOVELACE_UPDATED,
|
||||
EVENT_PANELS_UPDATED,
|
||||
EVENT_RECORDER_5MIN_STATISTICS_GENERATED,
|
||||
@@ -46,7 +45,6 @@ SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = {
|
||||
EVENT_STATE_CHANGED,
|
||||
EVENT_THEMES_UPDATED,
|
||||
EVENT_LABEL_REGISTRY_UPDATED,
|
||||
EVENT_LABS_UPDATED,
|
||||
EVENT_CATEGORY_REGISTRY_UPDATED,
|
||||
EVENT_FLOOR_REGISTRY_UPDATED,
|
||||
}
|
||||
|
||||
@@ -1000,7 +1000,7 @@ class _WatchPendingSetups:
|
||||
# We log every LOG_SLOW_STARTUP_INTERVAL until all integrations are done
|
||||
# once we take over LOG_SLOW_STARTUP_INTERVAL (60s) to start up
|
||||
_LOGGER.warning(
|
||||
"Waiting for integrations to complete setup: %s",
|
||||
"Waiting on integrations to complete setup: %s",
|
||||
self._setup_started,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"domain": "google",
|
||||
"name": "Google",
|
||||
"integrations": [
|
||||
"google_air_quality",
|
||||
"google_assistant",
|
||||
"google_assistant_sdk",
|
||||
"google_cloud",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "philips",
|
||||
"name": "Philips",
|
||||
"integrations": ["dynalite", "hue", "hue_ble", "philips_js"]
|
||||
"integrations": ["dynalite", "hue", "philips_js"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "raspberry_pi",
|
||||
"name": "Raspberry Pi",
|
||||
"integrations": ["raspberry_pi", "rpi_power", "remote_rpi_gpio"]
|
||||
"integrations": ["raspberry_pi", "rpi_camera", "rpi_power", "remote_rpi_gpio"]
|
||||
}
|
||||
|
||||
@@ -101,8 +101,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
vol.Schema({str: STRUCTURE_FIELD_SCHEMA}),
|
||||
_validate_structure_fields,
|
||||
),
|
||||
vol.Optional(ATTR_ATTACHMENTS): selector.MediaSelector(
|
||||
{"accept": ["*/*"], "multiple": True}
|
||||
vol.Optional(ATTR_ATTACHMENTS): vol.All(
|
||||
cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
|
||||
),
|
||||
}
|
||||
),
|
||||
@@ -118,8 +118,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
vol.Required(ATTR_TASK_NAME): cv.string,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(ATTR_INSTRUCTIONS): cv.string,
|
||||
vol.Optional(ATTR_ATTACHMENTS): selector.MediaSelector(
|
||||
{"accept": ["*/*"], "multiple": True}
|
||||
vol.Optional(ATTR_ATTACHMENTS): vol.All(
|
||||
cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
|
||||
),
|
||||
}
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:
|
||||
|
||||
@@ -75,19 +75,9 @@ class AirobotClimate(AirobotEntity, ClimateEntity):
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature.
|
||||
|
||||
If floor temperature is available, thermostat is set up for floor heating.
|
||||
"""
|
||||
if self._status.temp_floor is not None:
|
||||
return self._status.temp_floor
|
||||
"""Return the current temperature."""
|
||||
return self._status.temp_air
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the current humidity."""
|
||||
return self._status.hum_air
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
@@ -136,13 +126,6 @@ class AirobotClimate(AirobotEntity, ClimateEntity):
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set HVAC mode.
|
||||
|
||||
This thermostat only supports HEAT mode. The climate platform validates
|
||||
that only supported modes are passed, so this method is a no-op.
|
||||
"""
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
try:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -175,56 +174,6 @@ class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication dialog."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
# Combine existing data with new password
|
||||
data = {
|
||||
CONF_HOST: reauth_entry.data[CONF_HOST],
|
||||
CONF_USERNAME: reauth_entry.data[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
}
|
||||
|
||||
try:
|
||||
await validate_input(self.hass, data)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"username": reauth_entry.data[CONF_USERNAME],
|
||||
"host": reauth_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
@@ -11,7 +11,6 @@ from pyairobotrest.exceptions import AirobotAuthError, AirobotConnectionError
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
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
|
||||
|
||||
@@ -54,15 +53,7 @@ class AirobotDataUpdateCoordinator(DataUpdateCoordinator[AirobotData]):
|
||||
try:
|
||||
status = await self.client.get_statuses()
|
||||
settings = await self.client.get_settings()
|
||||
except AirobotAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_failed",
|
||||
) from err
|
||||
except AirobotConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_failed",
|
||||
) from err
|
||||
except (AirobotAuthError, AirobotConnectionError) as err:
|
||||
raise UpdateFailed(f"Failed to communicate with device: {err}") from err
|
||||
|
||||
return AirobotData(status=status, settings=settings)
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Diagnostics support for Airobot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AirobotConfigEntry
|
||||
|
||||
TO_REDACT_CONFIG = [CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME]
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: AirobotConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Build device capabilities info
|
||||
device_capabilities = None
|
||||
if coordinator.data:
|
||||
device_capabilities = {
|
||||
"has_floor_sensor": coordinator.data.status.has_floor_sensor,
|
||||
"has_co2_sensor": coordinator.data.status.has_co2_sensor,
|
||||
"hw_version": coordinator.data.status.hw_version,
|
||||
"fw_version": coordinator.data.status.fw_version,
|
||||
}
|
||||
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT_CONFIG),
|
||||
"device_capabilities": device_capabilities,
|
||||
"status": asdict(coordinator.data.status) if coordinator.data else None,
|
||||
"settings": asdict(coordinator.data.settings) if coordinator.data else None,
|
||||
}
|
||||
@@ -12,6 +12,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyairobotrest"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyairobotrest==0.1.0"]
|
||||
}
|
||||
|
||||
@@ -34,17 +34,17 @@ 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
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
@@ -54,14 +54,12 @@ rules:
|
||||
comment: Single device integration, no dynamic device discovery needed.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
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.
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Single device integration, no stale device handling needed.
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
"""Sensor platform for Airobot thermostat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyairobotrest.models import ThermostatStatus
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import AirobotConfigEntry
|
||||
from .entity import AirobotEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirobotSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Airobot sensor entity."""
|
||||
|
||||
value_fn: Callable[[ThermostatStatus], StateType]
|
||||
supported_fn: Callable[[ThermostatStatus], bool] = lambda _: True
|
||||
|
||||
|
||||
SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = (
|
||||
AirobotSensorEntityDescription(
|
||||
key="air_temperature",
|
||||
translation_key="air_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda status: status.temp_air,
|
||||
),
|
||||
AirobotSensorEntityDescription(
|
||||
key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda status: status.hum_air,
|
||||
),
|
||||
AirobotSensorEntityDescription(
|
||||
key="floor_temperature",
|
||||
translation_key="floor_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda status: status.temp_floor,
|
||||
supported_fn=lambda status: status.has_floor_sensor,
|
||||
),
|
||||
AirobotSensorEntityDescription(
|
||||
key="co2",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda status: status.co2,
|
||||
supported_fn=lambda status: status.has_co2_sensor,
|
||||
),
|
||||
AirobotSensorEntityDescription(
|
||||
key="air_quality_index",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda status: status.aqi,
|
||||
supported_fn=lambda status: status.has_co2_sensor,
|
||||
),
|
||||
AirobotSensorEntityDescription(
|
||||
key="heating_uptime",
|
||||
translation_key="heating_uptime",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda status: status.heating_uptime,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
AirobotSensorEntityDescription(
|
||||
key="errors",
|
||||
translation_key="errors",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda status: status.errors,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AirobotConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Airobot sensor platform."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
AirobotSensor(coordinator, description)
|
||||
for description in SENSOR_TYPES
|
||||
if description.supported_fn(coordinator.data.status)
|
||||
)
|
||||
|
||||
|
||||
class AirobotSensor(AirobotEntity, SensorEntity):
|
||||
"""Representation of an Airobot sensor."""
|
||||
|
||||
entity_description: AirobotSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator,
|
||||
description: AirobotSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data.status)
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -15,24 +14,15 @@
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
|
||||
"password": "The thermostat password."
|
||||
},
|
||||
"description": "Airobot thermostat {device_id} discovered at {host}. Enter the password to complete setup. Find the password in the thermostat settings menu under Connectivity → Mobile app."
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "The authentication for Airobot thermostat at {host} (Device ID: {username}) has expired. Please enter the password to reauthenticate. Find the password in the thermostat settings menu under Connectivity → Mobile app."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "Device ID"
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Airobot thermostat.",
|
||||
@@ -43,32 +33,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_temperature": {
|
||||
"name": "Air temperature"
|
||||
},
|
||||
"device_uptime": {
|
||||
"name": "Device uptime"
|
||||
},
|
||||
"errors": {
|
||||
"name": "Error count"
|
||||
},
|
||||
"floor_temperature": {
|
||||
"name": "Floor temperature"
|
||||
},
|
||||
"heating_uptime": {
|
||||
"name": "Heating uptime"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed, please reauthenticate."
|
||||
},
|
||||
"connection_failed": {
|
||||
"message": "Failed to communicate with device."
|
||||
},
|
||||
"set_preset_mode_failed": {
|
||||
"message": "Failed to set preset mode to {preset_mode}."
|
||||
},
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
"""The AirPatrol integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import AirPatrolConfigEntry, AirPatrolDataUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AirPatrolConfigEntry) -> bool:
|
||||
"""Set up AirPatrol from a config entry."""
|
||||
coordinator = AirPatrolDataUpdateCoordinator(hass, entry)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AirPatrolConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,208 +0,0 @@
|
||||
"""Climate platform for AirPatrol integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
SWING_OFF,
|
||||
SWING_ON,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AirPatrolConfigEntry
|
||||
from .coordinator import AirPatrolDataUpdateCoordinator
|
||||
from .entity import AirPatrolEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
AP_TO_HA_HVAC_MODES = {
|
||||
"heat": HVACMode.HEAT,
|
||||
"cool": HVACMode.COOL,
|
||||
"off": HVACMode.OFF,
|
||||
}
|
||||
HA_TO_AP_HVAC_MODES = {value: key for key, value in AP_TO_HA_HVAC_MODES.items()}
|
||||
|
||||
AP_TO_HA_FAN_MODES = {
|
||||
"min": FAN_LOW,
|
||||
"max": FAN_HIGH,
|
||||
"auto": FAN_AUTO,
|
||||
}
|
||||
HA_TO_AP_FAN_MODES = {value: key for key, value in AP_TO_HA_FAN_MODES.items()}
|
||||
|
||||
AP_TO_HA_SWING_MODES = {
|
||||
"on": SWING_ON,
|
||||
"off": SWING_OFF,
|
||||
}
|
||||
HA_TO_AP_SWING_MODES = {value: key for key, value in AP_TO_HA_SWING_MODES.items()}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: AirPatrolConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AirPatrol climate entities."""
|
||||
coordinator = config_entry.runtime_data
|
||||
units = coordinator.data
|
||||
|
||||
async_add_entities(
|
||||
AirPatrolClimate(coordinator, unit_id)
|
||||
for unit_id, unit in units.items()
|
||||
if "climate" in unit
|
||||
)
|
||||
|
||||
|
||||
class AirPatrolClimate(AirPatrolEntity, ClimateEntity):
|
||||
"""AirPatrol climate entity."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.SWING_MODE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF]
|
||||
_attr_fan_modes = [FAN_LOW, FAN_HIGH, FAN_AUTO]
|
||||
_attr_swing_modes = [SWING_ON, SWING_OFF]
|
||||
_attr_min_temp = 16.0
|
||||
_attr_max_temp = 30.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirPatrolDataUpdateCoordinator,
|
||||
unit_id: str,
|
||||
) -> None:
|
||||
"""Initialize the climate entity."""
|
||||
super().__init__(coordinator, unit_id)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}"
|
||||
|
||||
@property
|
||||
def climate_data(self) -> dict[str, Any]:
|
||||
"""Return the climate data."""
|
||||
return self.device_data.get("climate") or {}
|
||||
|
||||
@property
|
||||
def params(self) -> dict[str, Any]:
|
||||
"""Return the current parameters for the climate entity."""
|
||||
return self.climate_data.get("ParametersData") or {}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and bool(self.climate_data)
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the current humidity."""
|
||||
if humidity := self.climate_data.get("RoomHumidity"):
|
||||
return float(humidity)
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if temp := self.climate_data.get("RoomTemp"):
|
||||
return float(temp)
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
if temp := self.params.get("PumpTemp"):
|
||||
return float(temp)
|
||||
return None
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
pump_power = self.params.get("PumpPower")
|
||||
pump_mode = self.params.get("PumpMode")
|
||||
|
||||
if pump_power and pump_power == "on" and pump_mode:
|
||||
return AP_TO_HA_HVAC_MODES.get(pump_mode)
|
||||
return HVACMode.OFF
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
fan_speed = self.params.get("FanSpeed")
|
||||
if fan_speed:
|
||||
return AP_TO_HA_FAN_MODES.get(fan_speed)
|
||||
return None
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str | None:
|
||||
"""Return the current swing mode."""
|
||||
swing = self.params.get("Swing")
|
||||
if swing:
|
||||
return AP_TO_HA_SWING_MODES.get(swing)
|
||||
return None
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
params = self.params.copy()
|
||||
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
temp = kwargs[ATTR_TEMPERATURE]
|
||||
params["PumpTemp"] = f"{temp:.3f}"
|
||||
|
||||
await self._async_set_params(params)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
params = self.params.copy()
|
||||
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
params["PumpPower"] = "off"
|
||||
else:
|
||||
params["PumpPower"] = "on"
|
||||
params["PumpMode"] = HA_TO_AP_HVAC_MODES.get(hvac_mode)
|
||||
|
||||
await self._async_set_params(params)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
params = self.params.copy()
|
||||
params["FanSpeed"] = HA_TO_AP_FAN_MODES.get(fan_mode)
|
||||
|
||||
await self._async_set_params(params)
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set new target swing mode."""
|
||||
params = self.params.copy()
|
||||
params["Swing"] = HA_TO_AP_SWING_MODES.get(swing_mode)
|
||||
|
||||
await self._async_set_params(params)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the entity on."""
|
||||
params = self.params.copy()
|
||||
if mode := AP_TO_HA_HVAC_MODES.get(params["PumpMode"]):
|
||||
await self.async_set_hvac_mode(mode)
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the entity off."""
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||
|
||||
async def _async_set_params(self, params: dict[str, Any]) -> None:
|
||||
"""Set the unit to dry mode."""
|
||||
new_climate_data = self.climate_data.copy()
|
||||
new_climate_data["ParametersData"] = params
|
||||
|
||||
await self.coordinator.api.set_unit_climate_data(
|
||||
self._unit_id, new_climate_data
|
||||
)
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Config flow for the AirPatrol integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.EMAIL,
|
||||
autocomplete="email",
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.PASSWORD,
|
||||
autocomplete="current-password",
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_api(
|
||||
hass: HomeAssistant, user_input: dict[str, str]
|
||||
) -> tuple[str | None, str | None, dict[str, str]]:
|
||||
"""Validate the API connection."""
|
||||
errors: dict[str, str] = {}
|
||||
session = async_get_clientsession(hass)
|
||||
access_token = None
|
||||
unique_id = None
|
||||
try:
|
||||
api = await AirPatrolAPI.authenticate(
|
||||
session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except AirPatrolAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except AirPatrolError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
access_token = api.get_access_token()
|
||||
unique_id = api.get_unique_id()
|
||||
|
||||
return (access_token, unique_id, errors)
|
||||
|
||||
|
||||
class AirPatrolConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for AirPatrol."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
access_token, unique_id, errors = await validate_api(self.hass, user_input)
|
||||
if access_token and unique_id:
|
||||
user_input[CONF_ACCESS_TOKEN] = access_token
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_EMAIL], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, user_input: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication with new credentials."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input:
|
||||
access_token, unique_id, errors = await validate_api(self.hass, user_input)
|
||||
if access_token and unique_id:
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
user_input[CONF_ACCESS_TOKEN] = access_token
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Constants for the AirPatrol integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from airpatrol.api import AirPatrolAuthenticationError, AirPatrolError
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "airpatrol"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
PLATFORMS = [Platform.CLIMATE]
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Data update coordinator for AirPatrol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
|
||||
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
|
||||
|
||||
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
|
||||
|
||||
type AirPatrolConfigEntry = ConfigEntry[AirPatrolDataUpdateCoordinator]
|
||||
|
||||
|
||||
class AirPatrolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
|
||||
"""Class to manage fetching AirPatrol data."""
|
||||
|
||||
config_entry: AirPatrolConfigEntry
|
||||
api: AirPatrolAPI
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: AirPatrolConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=f"{DOMAIN.capitalize()} {config_entry.title}",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
try:
|
||||
await self._setup_client()
|
||||
except AirPatrolError as api_err:
|
||||
raise UpdateFailed(
|
||||
f"Error communicating with AirPatrol API: {api_err}"
|
||||
) from api_err
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
||||
"""Update unit data from AirPatrol API."""
|
||||
return {unit_data["unit_id"]: unit_data for unit_data in await self._get_data()}
|
||||
|
||||
async def _get_data(self, retry: bool = False) -> list[dict[str, Any]]:
|
||||
"""Fetch data from API."""
|
||||
try:
|
||||
return await self.api.get_data()
|
||||
except AirPatrolAuthenticationError as auth_err:
|
||||
if retry:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Authentication with AirPatrol failed"
|
||||
) from auth_err
|
||||
await self._update_token()
|
||||
return await self._get_data(retry=True)
|
||||
except AirPatrolError as err:
|
||||
raise UpdateFailed(
|
||||
f"Error communicating with AirPatrol API: {err}"
|
||||
) from err
|
||||
|
||||
async def _update_token(self) -> None:
|
||||
"""Refresh the AirPatrol API client and update the access token."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
self.api = await AirPatrolAPI.authenticate(
|
||||
session,
|
||||
self.config_entry.data[CONF_EMAIL],
|
||||
self.config_entry.data[CONF_PASSWORD],
|
||||
)
|
||||
except AirPatrolAuthenticationError as auth_err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Authentication with AirPatrol failed"
|
||||
) from auth_err
|
||||
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data={
|
||||
**self.config_entry.data,
|
||||
CONF_ACCESS_TOKEN: self.api.get_access_token(),
|
||||
},
|
||||
)
|
||||
|
||||
async def _setup_client(self) -> None:
|
||||
"""Set up the AirPatrol API client from stored access_token."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
api = AirPatrolAPI(
|
||||
session,
|
||||
self.config_entry.data[CONF_ACCESS_TOKEN],
|
||||
self.config_entry.unique_id,
|
||||
)
|
||||
try:
|
||||
await api.get_data()
|
||||
except AirPatrolAuthenticationError:
|
||||
await self._update_token()
|
||||
self.api = api
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Base entity for AirPatrol integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirPatrolDataUpdateCoordinator
|
||||
|
||||
|
||||
class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]):
|
||||
"""Base entity for AirPatrol devices."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirPatrolDataUpdateCoordinator,
|
||||
unit_id: str,
|
||||
) -> None:
|
||||
"""Initialize the AirPatrol entity."""
|
||||
super().__init__(coordinator)
|
||||
self._unit_id = unit_id
|
||||
device = coordinator.data[unit_id]
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unit_id)},
|
||||
name=device["name"],
|
||||
manufacturer=device["manufacturer"],
|
||||
model=device["model"],
|
||||
serial_number=device["hwid"],
|
||||
)
|
||||
|
||||
@property
|
||||
def device_data(self) -> dict[str, Any]:
|
||||
"""Return the device data."""
|
||||
return self.coordinator.data[self._unit_id]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._unit_id in self.coordinator.data
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "airpatrol",
|
||||
"name": "AirPatrol",
|
||||
"codeowners": ["@antondalgren"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airpatrol",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["airpatrol==0.1.0"]
|
||||
}
|
||||
@@ -1,65 +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:
|
||||
status: exempt
|
||||
comment: Integration does not provide custom actions
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities doesn't 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: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
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: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unique_id_mismatch": "Login credentials do not match the configured account"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "[%key:component::airpatrol::config::step::user::data_description::email%]",
|
||||
"password": "[%key:component::airpatrol::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "Reauthenticate with AirPatrol"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "Your AirPatrol email address",
|
||||
"password": "Your AirPatrol password"
|
||||
},
|
||||
"description": "Connect to AirPatrol"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairzone"],
|
||||
"requirements": ["aioairzone==1.0.4"]
|
||||
"requirements": ["aioairzone==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -36,28 +36,5 @@
|
||||
"alarm_trigger": {
|
||||
"service": "mdi:bell-ring"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"armed": {
|
||||
"trigger": "mdi:shield"
|
||||
},
|
||||
"armed_away": {
|
||||
"trigger": "mdi:shield-lock"
|
||||
},
|
||||
"armed_home": {
|
||||
"trigger": "mdi:shield-home"
|
||||
},
|
||||
"armed_night": {
|
||||
"trigger": "mdi:shield-moon"
|
||||
},
|
||||
"armed_vacation": {
|
||||
"trigger": "mdi:shield-airplane"
|
||||
},
|
||||
"disarmed": {
|
||||
"trigger": "mdi:shield-off"
|
||||
},
|
||||
"triggered": {
|
||||
"trigger": "mdi:bell-ring"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted alarms to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"arm_away": "Arm {entity_name} away",
|
||||
@@ -75,15 +71,6 @@
|
||||
"message": "Arming requires a code but none was given for {entity_id}."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"alarm_arm_away": {
|
||||
"description": "Arms the alarm in the away mode.",
|
||||
@@ -156,77 +143,5 @@
|
||||
"name": "Trigger"
|
||||
}
|
||||
},
|
||||
"title": "Alarm control panel",
|
||||
"triggers": {
|
||||
"armed": {
|
||||
"description": "Triggers after one or more alarms become armed, regardless of the mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm armed"
|
||||
},
|
||||
"armed_away": {
|
||||
"description": "Triggers after one or more alarms become armed in away mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm armed away"
|
||||
},
|
||||
"armed_home": {
|
||||
"description": "Triggers after one or more alarms become armed in home mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm armed home"
|
||||
},
|
||||
"armed_night": {
|
||||
"description": "Triggers after one or more alarms become armed in night mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm armed night"
|
||||
},
|
||||
"armed_vacation": {
|
||||
"description": "Triggers after one or more alarms become armed in vacation mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm armed vacation"
|
||||
},
|
||||
"disarmed": {
|
||||
"description": "Triggers after one or more alarms become disarmed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm disarmed"
|
||||
},
|
||||
"triggered": {
|
||||
"description": "Triggers after one or more alarms become triggered.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm triggered"
|
||||
}
|
||||
}
|
||||
"title": "Alarm control panel"
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Provides triggers for alarm control panels."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_supported_features
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityStateTriggerBase,
|
||||
Trigger,
|
||||
make_conditional_entity_state_trigger,
|
||||
make_entity_state_trigger,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
|
||||
|
||||
|
||||
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
try:
|
||||
return bool(get_supported_features(hass, entity_id) & features)
|
||||
except HomeAssistantError:
|
||||
return False
|
||||
|
||||
|
||||
class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_required_features: int
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if supports_feature(self._hass, entity_id, self._required_features)
|
||||
}
|
||||
|
||||
|
||||
def make_entity_state_trigger_required_features(
|
||||
domain: str, to_state: str, required_features: int
|
||||
) -> type[EntityStateTriggerBase]:
|
||||
"""Create an entity state trigger class."""
|
||||
|
||||
class CustomTrigger(EntityStateTriggerRequiredFeatures):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domain = domain
|
||||
_to_state = to_state
|
||||
_required_features = required_features
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"armed": make_conditional_entity_state_trigger(
|
||||
DOMAIN,
|
||||
from_states={
|
||||
AlarmControlPanelState.ARMING,
|
||||
AlarmControlPanelState.DISARMED,
|
||||
AlarmControlPanelState.DISARMING,
|
||||
AlarmControlPanelState.PENDING,
|
||||
AlarmControlPanelState.TRIGGERED,
|
||||
},
|
||||
to_states={
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
},
|
||||
),
|
||||
"armed_away": make_entity_state_trigger_required_features(
|
||||
DOMAIN,
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelEntityFeature.ARM_AWAY,
|
||||
),
|
||||
"armed_home": make_entity_state_trigger_required_features(
|
||||
DOMAIN,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelEntityFeature.ARM_HOME,
|
||||
),
|
||||
"armed_night": make_entity_state_trigger_required_features(
|
||||
DOMAIN,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelEntityFeature.ARM_NIGHT,
|
||||
),
|
||||
"armed_vacation": make_entity_state_trigger_required_features(
|
||||
DOMAIN,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
AlarmControlPanelEntityFeature.ARM_VACATION,
|
||||
),
|
||||
"disarmed": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.DISARMED),
|
||||
"triggered": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.TRIGGERED),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for alarm control panels."""
|
||||
return TRIGGERS
|
||||
@@ -1,53 +0,0 @@
|
||||
.trigger_common: &trigger_common
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
translation_key: trigger_behavior
|
||||
|
||||
armed: *trigger_common
|
||||
|
||||
armed_away:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
|
||||
armed_home:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
|
||||
|
||||
armed_night:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
|
||||
armed_vacation:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
|
||||
disarmed: *trigger_common
|
||||
|
||||
triggered: *trigger_common
|
||||
@@ -21,7 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
|
||||
|
||||
SCAN_INTERVAL = 300
|
||||
SCAN_INTERVAL = 30
|
||||
|
||||
type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
|
||||
|
||||
@@ -45,7 +45,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
config_entry=entry,
|
||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||
request_refresh_debouncer=Debouncer(
|
||||
hass, _LOGGER, cooldown=SCAN_INTERVAL, immediate=False
|
||||
hass, _LOGGER, cooldown=30, immediate=False
|
||||
),
|
||||
)
|
||||
self.api = AmazonEchoApi(
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==10.0.0"]
|
||||
"requirements": ["aioamazondevices==9.0.3"]
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""The Anglian Water integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from pyanglianwater import AnglianWater
|
||||
from pyanglianwater.auth import MSOB2CAuth
|
||||
from pyanglianwater.exceptions import (
|
||||
ExpiredAccessTokenError,
|
||||
SelfAssertedError,
|
||||
SmartMeterUnavailableError,
|
||||
)
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
||||
from .coordinator import AnglianWaterConfigEntry, AnglianWaterUpdateCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AnglianWaterConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Anglian Water from a config entry."""
|
||||
auth = MSOB2CAuth(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=async_create_clientsession(
|
||||
hass,
|
||||
cookie_jar=CookieJar(quote_cookie=False),
|
||||
),
|
||||
refresh_token=entry.data[CONF_ACCESS_TOKEN],
|
||||
)
|
||||
try:
|
||||
await auth.send_refresh_request()
|
||||
except (ExpiredAccessTokenError, SelfAssertedError) as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
|
||||
_aw = AnglianWater(authenticator=auth)
|
||||
|
||||
try:
|
||||
await _aw.validate_smart_meter(entry.data[CONF_ACCOUNT_NUMBER])
|
||||
except SmartMeterUnavailableError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN, translation_key="smart_meter_unavailable"
|
||||
) from err
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, CONF_ACCESS_TOKEN: auth.refresh_token}
|
||||
)
|
||||
entry.runtime_data = coordinator = AnglianWaterUpdateCoordinator(
|
||||
hass=hass, api=_aw, config_entry=entry
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: AnglianWaterConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Config flow for the Anglian Water integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from pyanglianwater import AnglianWater
|
||||
from pyanglianwater.auth import MSOB2CAuth
|
||||
from pyanglianwater.exceptions import (
|
||||
InvalidAccountIdError,
|
||||
SelfAssertedError,
|
||||
SmartMeterUnavailableError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): selector.TextSelector(),
|
||||
vol.Required(CONF_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(CONF_ACCOUNT_NUMBER): selector.TextSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_credentials(
|
||||
auth: MSOB2CAuth, account_number: str
|
||||
) -> str | MSOB2CAuth:
|
||||
"""Validate the provided credentials."""
|
||||
try:
|
||||
await auth.send_login_request()
|
||||
except SelfAssertedError:
|
||||
return "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return "unknown"
|
||||
_aw = AnglianWater(authenticator=auth)
|
||||
try:
|
||||
await _aw.validate_smart_meter(account_number)
|
||||
except (InvalidAccountIdError, SmartMeterUnavailableError):
|
||||
return "smart_meter_unavailable"
|
||||
return auth
|
||||
|
||||
|
||||
class AnglianWaterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Anglian Water."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
validation_response = await validate_credentials(
|
||||
MSOB2CAuth(
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
session=async_create_clientsession(
|
||||
self.hass,
|
||||
cookie_jar=CookieJar(quote_cookie=False),
|
||||
),
|
||||
),
|
||||
user_input[CONF_ACCOUNT_NUMBER],
|
||||
)
|
||||
if isinstance(validation_response, str):
|
||||
errors["base"] = validation_response
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_ACCOUNT_NUMBER])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_ACCOUNT_NUMBER],
|
||||
data={
|
||||
**user_input,
|
||||
CONF_ACCESS_TOKEN: validation_response.refresh_token,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Constants for the Anglian Water integration."""
|
||||
|
||||
DOMAIN = "anglian_water"
|
||||
CONF_ACCOUNT_NUMBER = "account_number"
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Anglian Water data coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyanglianwater import AnglianWater
|
||||
from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
||||
|
||||
type AnglianWaterConfigEntry = ConfigEntry[AnglianWaterUpdateCoordinator]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_INTERVAL = timedelta(minutes=60)
|
||||
|
||||
|
||||
class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Anglian Water data update coordinator."""
|
||||
|
||||
config_entry: AnglianWaterConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
api: AnglianWater,
|
||||
config_entry: AnglianWaterConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize update coordinator."""
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
self.api = api
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update data from Anglian Water's API."""
|
||||
try:
|
||||
return await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
|
||||
except (ExpiredAccessTokenError, UnknownEndpointError) as err:
|
||||
raise UpdateFailed from err
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Anglian Water entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pyanglianwater.meter import SmartMeter
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AnglianWaterUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnglianWaterEntity(CoordinatorEntity[AnglianWaterUpdateCoordinator]):
|
||||
"""Defines a Anglian Water entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AnglianWaterUpdateCoordinator,
|
||||
smart_meter: SmartMeter,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize Anglian Water entity."""
|
||||
super().__init__(coordinator)
|
||||
self.smart_meter = smart_meter
|
||||
self._attr_unique_id = f"{smart_meter.serial_number}_{key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, smart_meter.serial_number)},
|
||||
name=smart_meter.serial_number,
|
||||
manufacturer="Anglian Water",
|
||||
serial_number=smart_meter.serial_number,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is loaded."""
|
||||
self.coordinator.api.updated_data_callbacks.append(self.async_write_ha_state)
|
||||
await super().async_added_to_hass()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""When will be removed from HASS."""
|
||||
self.coordinator.api.updated_data_callbacks.remove(self.async_write_ha_state)
|
||||
await super().async_will_remove_from_hass()
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "anglian_water",
|
||||
"name": "Anglian Water",
|
||||
"codeowners": ["@pantherale0"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyanglianwater"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyanglianwater==3.0.0"]
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
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:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
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: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
Unable to discover meters.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
Unable to discover meters.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
No entities are disabled by default.
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities do not require different icons.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
Read-only integration and no repairs are possible.
|
||||
stale-devices: todo
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -1,117 +0,0 @@
|
||||
"""Anglian Water sensor platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from pyanglianwater.meter import SmartMeter
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
EntityCategory,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AnglianWaterConfigEntry, AnglianWaterUpdateCoordinator
|
||||
from .entity import AnglianWaterEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class AnglianWaterSensor(StrEnum):
|
||||
"""Store keys for Anglian Water sensors."""
|
||||
|
||||
YESTERDAY_CONSUMPTION = "yesterday_consumption"
|
||||
YESTERDAY_WATER_COST = "yesterday_water_cost"
|
||||
YESTERDAY_SEWERAGE_COST = "yesterday_sewerage_cost"
|
||||
LATEST_READING = "latest_reading"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AnglianWaterSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes AnglianWater sensor entity."""
|
||||
|
||||
value_fn: Callable[[SmartMeter], float]
|
||||
|
||||
|
||||
ENTITY_DESCRIPTIONS: tuple[AnglianWaterSensorEntityDescription, ...] = (
|
||||
AnglianWaterSensorEntityDescription(
|
||||
key=AnglianWaterSensor.YESTERDAY_CONSUMPTION,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
device_class=SensorDeviceClass.WATER,
|
||||
value_fn=lambda entity: entity.get_yesterday_consumption,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
translation_key=AnglianWaterSensor.YESTERDAY_CONSUMPTION,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
AnglianWaterSensorEntityDescription(
|
||||
key=AnglianWaterSensor.LATEST_READING,
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
device_class=SensorDeviceClass.WATER,
|
||||
value_fn=lambda entity: entity.latest_read,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
translation_key=AnglianWaterSensor.LATEST_READING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
AnglianWaterSensorEntityDescription(
|
||||
key=AnglianWaterSensor.YESTERDAY_WATER_COST,
|
||||
native_unit_of_measurement="GBP",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
value_fn=lambda entity: entity.yesterday_water_cost,
|
||||
translation_key=AnglianWaterSensor.YESTERDAY_WATER_COST,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
AnglianWaterSensorEntityDescription(
|
||||
key=AnglianWaterSensor.YESTERDAY_SEWERAGE_COST,
|
||||
native_unit_of_measurement="GBP",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
value_fn=lambda entity: entity.yesterday_sewerage_cost,
|
||||
translation_key=AnglianWaterSensor.YESTERDAY_SEWERAGE_COST,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AnglianWaterConfigEntry,
|
||||
async_add_devices: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the sensor platform."""
|
||||
async_add_devices(
|
||||
AnglianWaterSensorEntity(
|
||||
coordinator=entry.runtime_data,
|
||||
description=entity_description,
|
||||
smart_meter=smart_meter,
|
||||
)
|
||||
for entity_description in ENTITY_DESCRIPTIONS
|
||||
for smart_meter in entry.runtime_data.api.meters.values()
|
||||
)
|
||||
|
||||
|
||||
class AnglianWaterSensorEntity(AnglianWaterEntity, SensorEntity):
|
||||
"""Defines a Anglian Water sensor."""
|
||||
|
||||
entity_description: AnglianWaterSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AnglianWaterUpdateCoordinator,
|
||||
smart_meter: SmartMeter,
|
||||
description: AnglianWaterSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize Anglian Water sensor."""
|
||||
super().__init__(coordinator, smart_meter, description.key)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.smart_meter)
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"smart_meter_unavailable": "This account does not have any smart meters associated with it. If this is unexpected, enter your Billing Account Number found at the top of your latest bill.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"account_number": "Billing Account Number",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"account_number": "Your account number found on your latest bill.",
|
||||
"password": "Your password",
|
||||
"username": "Username or email used to log in to the Anglian Water website."
|
||||
},
|
||||
"description": "Enter your Anglian Water account credentials to connect to Home Assistant."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"latest_reading": {
|
||||
"name": "Latest reading"
|
||||
},
|
||||
"yesterday_consumption": {
|
||||
"name": "Yesterday's usage"
|
||||
},
|
||||
"yesterday_sewerage_cost": {
|
||||
"name": "Yesterday's sewerage cost"
|
||||
},
|
||||
"yesterday_water_cost": {
|
||||
"name": "Yesterday's water cost"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_expired": {
|
||||
"message": "Authentication token expired"
|
||||
},
|
||||
"service_unavailable": {
|
||||
"message": "Anglian Water services are currently unavailable for maintenance."
|
||||
},
|
||||
"smart_meter_unavailable": {
|
||||
"message": "This account no longer has a smart meter associated with it."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,13 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
)
|
||||
|
||||
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
@@ -37,7 +43,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||
partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
|
||||
)
|
||||
try:
|
||||
await client.models.list(timeout=10.0)
|
||||
# Use model from first conversation subentry for validation
|
||||
subentries = list(entry.subentries.values())
|
||||
if subentries:
|
||||
model_id = subentries[0].data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
else:
|
||||
model_id = RECOMMENDED_CHAT_MODEL
|
||||
model = await client.models.retrieve(model_id=model_id, timeout=10.0)
|
||||
LOGGER.debug("Anthropic model: %s", model.display_name)
|
||||
except anthropic.AuthenticationError as err:
|
||||
LOGGER.error("Invalid API key: %s", err)
|
||||
return False
|
||||
|
||||
@@ -6,7 +6,7 @@ from functools import partial
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
import voluptuous as vol
|
||||
@@ -54,11 +54,17 @@ from .const import (
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
NON_THINKING_MODELS,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_THINKING_BUDGET,
|
||||
RECOMMENDED_WEB_SEARCH,
|
||||
RECOMMENDED_WEB_SEARCH_MAX_USES,
|
||||
RECOMMENDED_WEB_SEARCH_USER_LOCATION,
|
||||
WEB_SEARCH_UNSUPPORTED_MODELS,
|
||||
)
|
||||
|
||||
@@ -70,13 +76,13 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_CONVERSATION_OPTIONS = {
|
||||
RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
}
|
||||
|
||||
DEFAULT_AI_TASK_OPTIONS = {
|
||||
RECOMMENDED_AI_TASK_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
|
||||
@@ -130,13 +136,13 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": "conversation",
|
||||
"data": DEFAULT_CONVERSATION_OPTIONS,
|
||||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"subentry_type": "ai_task_data",
|
||||
"data": DEFAULT_AI_TASK_OPTIONS,
|
||||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
@@ -174,9 +180,9 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
) -> SubentryFlowResult:
|
||||
"""Add a subentry."""
|
||||
if self._subentry_type == "ai_task_data":
|
||||
self.options = DEFAULT_AI_TASK_OPTIONS.copy()
|
||||
self.options = RECOMMENDED_AI_TASK_OPTIONS.copy()
|
||||
else:
|
||||
self.options = DEFAULT_CONVERSATION_OPTIONS.copy()
|
||||
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
return await self.async_step_init()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
@@ -277,7 +283,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
step_schema: VolDictType = {
|
||||
vol.Optional(
|
||||
CONF_CHAT_MODEL,
|
||||
default=DEFAULT[CONF_CHAT_MODEL],
|
||||
default=RECOMMENDED_CHAT_MODEL,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=await self._get_model_list(), custom_value=True
|
||||
@@ -285,11 +291,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_MAX_TOKENS,
|
||||
default=DEFAULT[CONF_MAX_TOKENS],
|
||||
default=RECOMMENDED_MAX_TOKENS,
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_TEMPERATURE,
|
||||
default=DEFAULT[CONF_TEMPERATURE],
|
||||
default=RECOMMENDED_TEMPERATURE,
|
||||
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
|
||||
}
|
||||
|
||||
@@ -319,14 +325,12 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
|
||||
if not model.startswith(tuple(NON_THINKING_MODELS)):
|
||||
step_schema[
|
||||
vol.Optional(
|
||||
CONF_THINKING_BUDGET, default=DEFAULT[CONF_THINKING_BUDGET]
|
||||
)
|
||||
vol.Optional(CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET)
|
||||
] = vol.All(
|
||||
NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=0,
|
||||
max=self.options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
|
||||
max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
@@ -339,15 +343,15 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH,
|
||||
default=DEFAULT[CONF_WEB_SEARCH],
|
||||
default=RECOMMENDED_WEB_SEARCH,
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_MAX_USES,
|
||||
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
|
||||
default=RECOMMENDED_WEB_SEARCH_MAX_USES,
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
|
||||
default=RECOMMENDED_WEB_SEARCH_USER_LOCATION,
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
@@ -365,10 +369,9 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
user_input = {}
|
||||
|
||||
if user_input is not None:
|
||||
if user_input.get(CONF_WEB_SEARCH, DEFAULT[CONF_WEB_SEARCH]) and not errors:
|
||||
if user_input.get(CONF_WEB_SEARCH, RECOMMENDED_WEB_SEARCH) and not errors:
|
||||
if user_input.get(
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
|
||||
CONF_WEB_SEARCH_USER_LOCATION, RECOMMENDED_WEB_SEARCH_USER_LOCATION
|
||||
):
|
||||
user_input.update(await self._get_location_data())
|
||||
|
||||
@@ -421,8 +424,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
if model_alias.endswith(("haiku", "opus", "sonnet")):
|
||||
model_alias += "-latest"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
@@ -455,7 +456,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
}
|
||||
)
|
||||
response = await client.messages.create(
|
||||
model=cast(str, DEFAULT[CONF_CHAT_MODEL]),
|
||||
model=RECOMMENDED_CHAT_MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
@@ -470,7 +471,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"content": "{", # hints the model to skip any preamble
|
||||
},
|
||||
],
|
||||
max_tokens=cast(int, DEFAULT[CONF_MAX_TOKENS]),
|
||||
max_tokens=RECOMMENDED_MAX_TOKENS,
|
||||
)
|
||||
_LOGGER.debug("Model response: %s", response.content)
|
||||
location_data = location_schema(
|
||||
|
||||
@@ -11,29 +11,25 @@ DEFAULT_AI_TASK_NAME = "Claude AI Task"
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_PROMPT = "prompt"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
RECOMMENDED_CHAT_MODEL = "claude-3-5-haiku-latest"
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
RECOMMENDED_MAX_TOKENS = 3000
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
RECOMMENDED_TEMPERATURE = 1.0
|
||||
CONF_THINKING_BUDGET = "thinking_budget"
|
||||
RECOMMENDED_THINKING_BUDGET = 0
|
||||
MIN_THINKING_BUDGET = 1024
|
||||
CONF_WEB_SEARCH = "web_search"
|
||||
RECOMMENDED_WEB_SEARCH = False
|
||||
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
|
||||
RECOMMENDED_WEB_SEARCH_USER_LOCATION = False
|
||||
CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses"
|
||||
RECOMMENDED_WEB_SEARCH_MAX_USES = 5
|
||||
CONF_WEB_SEARCH_CITY = "city"
|
||||
CONF_WEB_SEARCH_REGION = "region"
|
||||
CONF_WEB_SEARCH_COUNTRY = "country"
|
||||
CONF_WEB_SEARCH_TIMEZONE = "timezone"
|
||||
|
||||
DEFAULT = {
|
||||
CONF_CHAT_MODEL: "claude-3-5-haiku-latest",
|
||||
CONF_MAX_TOKENS: 3000,
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_THINKING_BUDGET: 0,
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
}
|
||||
|
||||
MIN_THINKING_BUDGET = 1024
|
||||
|
||||
NON_THINKING_MODELS = [
|
||||
"claude-3-5", # Both sonnet and haiku
|
||||
"claude-3-opus",
|
||||
|
||||
@@ -84,11 +84,14 @@ from .const import (
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_THINKING_BUDGET,
|
||||
NON_THINKING_MODELS,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_THINKING_BUDGET,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
@@ -583,7 +586,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Anthropic",
|
||||
model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]),
|
||||
model="Claude",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@@ -601,19 +604,17 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
raise TypeError("First message must be a system message")
|
||||
messages = _convert_content(chat_log.content[1:])
|
||||
|
||||
model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
|
||||
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
|
||||
model_args = MessageCreateParamsStreaming(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
|
||||
max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
system=system.content,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
thinking_budget = options.get(
|
||||
CONF_THINKING_BUDGET, DEFAULT[CONF_THINKING_BUDGET]
|
||||
)
|
||||
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
|
||||
if (
|
||||
not model.startswith(tuple(NON_THINKING_MODELS))
|
||||
and thinking_budget >= MIN_THINKING_BUDGET
|
||||
@@ -624,7 +625,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
else:
|
||||
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
|
||||
model_args["temperature"] = options.get(
|
||||
CONF_TEMPERATURE, DEFAULT[CONF_TEMPERATURE]
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
)
|
||||
|
||||
tools: list[ToolUnionParam] = []
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.75.0"]
|
||||
"requirements": ["anthropic==0.73.0"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["zeroconf"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyatv", "srptools"],
|
||||
"requirements": ["pyatv==0.16.1;python_version<'3.14'"],
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import math
|
||||
|
||||
from pysilero_vad import SileroVoiceActivityDetector
|
||||
from pymicro_vad import MicroVad
|
||||
from pyspeex_noise import AudioProcessor
|
||||
|
||||
from .const import BYTES_PER_CHUNK
|
||||
@@ -43,8 +42,8 @@ class AudioEnhancer(ABC):
|
||||
"""Enhance chunk of PCM audio @ 16Khz with 16-bit mono samples."""
|
||||
|
||||
|
||||
class SileroVadSpeexEnhancer(AudioEnhancer):
|
||||
"""Audio enhancer that runs Silero VAD and speex."""
|
||||
class MicroVadSpeexEnhancer(AudioEnhancer):
|
||||
"""Audio enhancer that runs microVAD and speex."""
|
||||
|
||||
def __init__(
|
||||
self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool
|
||||
@@ -70,49 +69,21 @@ class SileroVadSpeexEnhancer(AudioEnhancer):
|
||||
self.noise_suppression,
|
||||
)
|
||||
|
||||
self.vad: SileroVoiceActivityDetector | None = None
|
||||
|
||||
# We get 10ms chunks but Silero works on 32ms chunks, so we have to
|
||||
# buffer audio. The previous speech probability is used until enough
|
||||
# audio has been buffered.
|
||||
self._vad_buffer: bytearray | None = None
|
||||
self._vad_buffer_chunks = 0
|
||||
self._vad_buffer_chunk_idx = 0
|
||||
self._last_speech_probability: float | None = None
|
||||
self.vad: MicroVad | None = None
|
||||
|
||||
if self.is_vad_enabled:
|
||||
self.vad = SileroVoiceActivityDetector()
|
||||
|
||||
# VAD buffer is a multiple of 10ms, but Silero VAD needs 32ms.
|
||||
self._vad_buffer_chunks = int(
|
||||
math.ceil(self.vad.chunk_bytes() / BYTES_PER_CHUNK)
|
||||
)
|
||||
self._vad_leftover_bytes = self.vad.chunk_bytes() - BYTES_PER_CHUNK
|
||||
self._vad_buffer = bytearray(self.vad.chunk_bytes())
|
||||
_LOGGER.debug("Initialized Silero VAD")
|
||||
self.vad = MicroVad()
|
||||
_LOGGER.debug("Initialized microVAD")
|
||||
|
||||
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
|
||||
"""Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples."""
|
||||
speech_probability: float | None = None
|
||||
|
||||
assert len(audio) == BYTES_PER_CHUNK
|
||||
|
||||
if self.vad is not None:
|
||||
# Run VAD
|
||||
assert self._vad_buffer is not None
|
||||
start_idx = self._vad_buffer_chunk_idx * BYTES_PER_CHUNK
|
||||
self._vad_buffer[start_idx : start_idx + BYTES_PER_CHUNK] = audio
|
||||
|
||||
self._vad_buffer_chunk_idx += 1
|
||||
if self._vad_buffer_chunk_idx >= self._vad_buffer_chunks:
|
||||
# We have enough data to run Silero VAD (32 ms)
|
||||
self._last_speech_probability = self.vad.process_chunk(
|
||||
self._vad_buffer[: self.vad.chunk_bytes()]
|
||||
)
|
||||
|
||||
# Copy leftover audio that wasn't processed to start
|
||||
self._vad_buffer[: self._vad_leftover_bytes] = self._vad_buffer[
|
||||
-self._vad_leftover_bytes :
|
||||
]
|
||||
self._vad_buffer_chunk_idx = 0
|
||||
speech_probability = self.vad.Process10ms(audio)
|
||||
|
||||
if self.audio_processor is not None:
|
||||
# Run noise suppression and auto gain
|
||||
@@ -121,5 +92,5 @@ class SileroVadSpeexEnhancer(AudioEnhancer):
|
||||
return EnhancedAudioChunk(
|
||||
audio=audio,
|
||||
timestamp_ms=timestamp_ms,
|
||||
speech_probability=self._last_speech_probability,
|
||||
speech_probability=speech_probability,
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["pysilero-vad==3.0.1", "pyspeex-noise==1.0.2"]
|
||||
"requirements": ["pymicro-vad==1.0.1", "pyspeex-noise==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ from homeassistant.util import (
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.limited_size_dict import LimitedSizeDict
|
||||
|
||||
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, SileroVadSpeexEnhancer
|
||||
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer
|
||||
from .const import (
|
||||
ACKNOWLEDGE_PATH,
|
||||
BYTES_PER_CHUNK,
|
||||
@@ -633,7 +633,7 @@ class PipelineRun:
|
||||
# Initialize with audio settings
|
||||
if self.audio_settings.needs_processor and (self.audio_enhancer is None):
|
||||
# Default audio enhancer
|
||||
self.audio_enhancer = SileroVadSpeexEnhancer(
|
||||
self.audio_enhancer = MicroVadSpeexEnhancer(
|
||||
self.audio_settings.auto_gain_dbfs,
|
||||
self.audio_settings.noise_suppression_level,
|
||||
self.audio_settings.is_vad_enabled,
|
||||
@@ -1123,6 +1123,63 @@ class PipelineRun:
|
||||
)
|
||||
|
||||
try:
|
||||
user_input = conversation.ConversationInput(
|
||||
text=intent_input,
|
||||
context=self.context,
|
||||
conversation_id=conversation_id,
|
||||
device_id=self._device_id,
|
||||
satellite_id=self._satellite_id,
|
||||
language=input_language,
|
||||
agent_id=self.intent_agent.id,
|
||||
extra_system_prompt=conversation_extra_system_prompt,
|
||||
)
|
||||
|
||||
agent_id = self.intent_agent.id
|
||||
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
|
||||
all_targets_in_satellite_area = False
|
||||
intent_response: intent.IntentResponse | None = None
|
||||
if not processed_locally and not self._intent_agent_only:
|
||||
# Sentence triggers override conversation agent
|
||||
if (
|
||||
trigger_response_text
|
||||
:= await conversation.async_handle_sentence_triggers(
|
||||
self.hass, user_input
|
||||
)
|
||||
) is not None:
|
||||
# Sentence trigger matched
|
||||
agent_id = "sentence_trigger"
|
||||
processed_locally = True
|
||||
intent_response = intent.IntentResponse(
|
||||
self.pipeline.conversation_language
|
||||
)
|
||||
intent_response.async_set_speech(trigger_response_text)
|
||||
|
||||
intent_filter: Callable[[RecognizeResult], bool] | None = None
|
||||
# If the LLM has API access, we filter out some sentences that are
|
||||
# interfering with LLM operation.
|
||||
if (
|
||||
intent_agent_state := self.hass.states.get(self.intent_agent.id)
|
||||
) and intent_agent_state.attributes.get(
|
||||
ATTR_SUPPORTED_FEATURES, 0
|
||||
) & conversation.ConversationEntityFeature.CONTROL:
|
||||
intent_filter = _async_local_fallback_intent_filter
|
||||
|
||||
# Try local intents
|
||||
if (
|
||||
intent_response is None
|
||||
and self.pipeline.prefer_local_intents
|
||||
and (
|
||||
intent_response := await conversation.async_handle_intents(
|
||||
self.hass,
|
||||
user_input,
|
||||
intent_filter=intent_filter,
|
||||
)
|
||||
)
|
||||
):
|
||||
# Local intent matched
|
||||
agent_id = conversation.HOME_ASSISTANT_AGENT
|
||||
processed_locally = True
|
||||
|
||||
if self.tts_stream and self.tts_stream.supports_streaming_input:
|
||||
tts_input_stream: asyncio.Queue[str | None] | None = asyncio.Queue()
|
||||
else:
|
||||
@@ -1208,17 +1265,6 @@ class PipelineRun:
|
||||
assert self.tts_stream is not None
|
||||
self.tts_stream.async_set_message_stream(tts_input_stream_generator())
|
||||
|
||||
user_input = conversation.ConversationInput(
|
||||
text=intent_input,
|
||||
context=self.context,
|
||||
conversation_id=conversation_id,
|
||||
device_id=self._device_id,
|
||||
satellite_id=self._satellite_id,
|
||||
language=input_language,
|
||||
agent_id=self.intent_agent.id,
|
||||
extra_system_prompt=conversation_extra_system_prompt,
|
||||
)
|
||||
|
||||
with (
|
||||
chat_session.async_get_chat_session(
|
||||
self.hass, user_input.conversation_id
|
||||
@@ -1230,53 +1276,6 @@ class PipelineRun:
|
||||
chat_log_delta_listener=chat_log_delta_listener,
|
||||
) as chat_log,
|
||||
):
|
||||
agent_id = self.intent_agent.id
|
||||
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
|
||||
all_targets_in_satellite_area = False
|
||||
intent_response: intent.IntentResponse | None = None
|
||||
if not processed_locally and not self._intent_agent_only:
|
||||
# Sentence triggers override conversation agent
|
||||
if (
|
||||
trigger_response_text
|
||||
:= await conversation.async_handle_sentence_triggers(
|
||||
self.hass, user_input, chat_log
|
||||
)
|
||||
) is not None:
|
||||
# Sentence trigger matched
|
||||
agent_id = "sentence_trigger"
|
||||
processed_locally = True
|
||||
intent_response = intent.IntentResponse(
|
||||
self.pipeline.conversation_language
|
||||
)
|
||||
intent_response.async_set_speech(trigger_response_text)
|
||||
|
||||
intent_filter: Callable[[RecognizeResult], bool] | None = None
|
||||
# If the LLM has API access, we filter out some sentences that are
|
||||
# interfering with LLM operation.
|
||||
if (
|
||||
intent_agent_state := self.hass.states.get(self.intent_agent.id)
|
||||
) and intent_agent_state.attributes.get(
|
||||
ATTR_SUPPORTED_FEATURES, 0
|
||||
) & conversation.ConversationEntityFeature.CONTROL:
|
||||
intent_filter = _async_local_fallback_intent_filter
|
||||
|
||||
# Try local intents
|
||||
if (
|
||||
intent_response is None
|
||||
and self.pipeline.prefer_local_intents
|
||||
and (
|
||||
intent_response := await conversation.async_handle_intents(
|
||||
self.hass,
|
||||
user_input,
|
||||
chat_log,
|
||||
intent_filter=intent_filter,
|
||||
)
|
||||
)
|
||||
):
|
||||
# Local intent matched
|
||||
agent_id = conversation.HOME_ASSISTANT_AGENT
|
||||
processed_locally = True
|
||||
|
||||
# It was already handled, create response and add to chat history
|
||||
if intent_response is not None:
|
||||
speech: str = intent_response.speech.get("plain", {}).get(
|
||||
|
||||
@@ -14,19 +14,5 @@
|
||||
"start_conversation": {
|
||||
"service": "mdi:forum"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"idle": {
|
||||
"trigger": "mdi:chat-sleep"
|
||||
},
|
||||
"listening": {
|
||||
"trigger": "mdi:chat-question"
|
||||
},
|
||||
"processing": {
|
||||
"trigger": "mdi:chat-processing"
|
||||
},
|
||||
"responding": {
|
||||
"trigger": "mdi:chat-alert"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0"]
|
||||
"requirements": ["hassil==3.4.0"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "Assist satellite",
|
||||
@@ -20,13 +16,6 @@
|
||||
"id": "Answer ID",
|
||||
"sentences": "Sentences"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -109,47 +98,5 @@
|
||||
"name": "Start conversation"
|
||||
}
|
||||
},
|
||||
"title": "Assist satellite",
|
||||
"triggers": {
|
||||
"idle": {
|
||||
"description": "Triggers after one or more voice assistant satellites become idle after having processed a command.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Satellite became idle"
|
||||
},
|
||||
"listening": {
|
||||
"description": "Triggers after one or more voice assistant satellites start listening for a command from someone.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Satellite started listening"
|
||||
},
|
||||
"processing": {
|
||||
"description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Satellite started processing"
|
||||
},
|
||||
"responding": {
|
||||
"description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Satellite started responding"
|
||||
}
|
||||
}
|
||||
"title": "Assist satellite"
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Provides triggers for assist satellites."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import AssistSatelliteState
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"idle": make_entity_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
|
||||
"listening": make_entity_state_trigger(DOMAIN, AssistSatelliteState.LISTENING),
|
||||
"processing": make_entity_state_trigger(DOMAIN, AssistSatelliteState.PROCESSING),
|
||||
"responding": make_entity_state_trigger(DOMAIN, AssistSatelliteState.RESPONDING),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for assist satellites."""
|
||||
return TRIGGERS
|
||||
@@ -1,20 +0,0 @@
|
||||
.trigger_common: &trigger_common
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
translation_key: trigger_behavior
|
||||
|
||||
idle: *trigger_common
|
||||
listening: *trigger_common
|
||||
processing: *trigger_common
|
||||
responding: *trigger_common
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioasuswrt", "asusrouter", "asyncssh"],
|
||||
"requirements": ["aioasuswrt==1.5.1", "asusrouter==1.21.3"]
|
||||
"requirements": ["aioasuswrt==1.5.1", "asusrouter==1.21.0"]
|
||||
}
|
||||
|
||||
@@ -29,5 +29,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.2"]
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.1.2"]
|
||||
}
|
||||
|
||||
@@ -12,9 +12,8 @@ from typing import Any, Protocol, cast
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
|
||||
from homeassistant.components.labs import async_listen as async_labs_listen
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_MODE,
|
||||
@@ -115,52 +114,6 @@ ATTR_SOURCE = "source"
|
||||
ATTR_VARIABLES = "variables"
|
||||
SERVICE_TRIGGER = "trigger"
|
||||
|
||||
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
|
||||
|
||||
_EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"light",
|
||||
}
|
||||
|
||||
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"binary_sensor",
|
||||
"climate",
|
||||
"cover",
|
||||
"fan",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"media_player",
|
||||
"text",
|
||||
"vacuum",
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def is_disabled_experimental_condition(hass: HomeAssistant, platform: str) -> bool:
|
||||
"""Check if the platform is a disabled experimental condition platform."""
|
||||
return (
|
||||
platform in _EXPERIMENTAL_CONDITION_PLATFORMS
|
||||
and not labs.async_is_preview_feature_enabled(
|
||||
hass,
|
||||
DOMAIN,
|
||||
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def is_disabled_experimental_trigger(hass: HomeAssistant, platform: str) -> bool:
|
||||
"""Check if the platform is a disabled experimental trigger platform."""
|
||||
return (
|
||||
platform in _EXPERIMENTAL_TRIGGER_PLATFORMS
|
||||
and not labs.async_is_preview_feature_enabled(
|
||||
hass,
|
||||
DOMAIN,
|
||||
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class IfAction(Protocol):
|
||||
"""Define the format of if_action."""
|
||||
@@ -364,20 +317,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
schema=vol.Schema({vol.Optional(CONF_ID): str}),
|
||||
)
|
||||
|
||||
@callback
|
||||
def new_triggers_conditions_listener() -> None:
|
||||
"""Handle new_triggers_conditions flag change."""
|
||||
hass.async_create_task(
|
||||
reload_helper.execute_service(ServiceCall(hass, DOMAIN, SERVICE_RELOAD))
|
||||
)
|
||||
|
||||
async_labs_listen(
|
||||
hass,
|
||||
DOMAIN,
|
||||
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG,
|
||||
new_triggers_conditions_listener,
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_config)
|
||||
|
||||
return True
|
||||
|
||||
@@ -6,12 +6,5 @@
|
||||
"dependencies": ["blueprint", "trace"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/automation",
|
||||
"integration_type": "system",
|
||||
"preview_features": {
|
||||
"new_triggers_conditions": {
|
||||
"feedback_url": "https://forms.gle/fWFZqf5MzuwWTsCH8",
|
||||
"learn_more_url": "https://www.home-assistant.io/blog/2025/12/03/release-202512/#purpose-specific-triggers-and-conditions",
|
||||
"report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/automation&integration_name=Automation"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
||||
@@ -67,14 +67,6 @@
|
||||
"title": "[%key:component::automation::common::validation_failed_title%]"
|
||||
}
|
||||
},
|
||||
"preview_features": {
|
||||
"new_triggers_conditions": {
|
||||
"description": "Enables new purpose-specific triggers and conditions that are more user-friendly than technical state-based options.\n\nThese new automation features support targets across your entire home, letting you trigger automations for any entity, device, area, floor, or label (for example, when any light in your living room turned on). Integrations can now also provide their own purpose-specific triggers and conditions, just like actions.\n\nThis preview also includes a new tree view to help you navigate your home when adding triggers, conditions, and actions.",
|
||||
"disable_confirmation": "Disabling this preview will cause automations and scripts that use the new purpose-specific triggers and conditions to fail.\n\nBefore disabling, ensure that your automations or scripts do not rely on this feature.",
|
||||
"enable_confirmation": "This feature is still in development and may change. These new purpose-specific triggers and conditions are being refined based on user feedback and are not yet complete.\n\nBy enabling this preview, you'll have early access to these new capabilities, but be aware that they may be modified or updated in future releases.",
|
||||
"name": "Purpose-specific triggers and conditions"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload": {
|
||||
"description": "Reloads the automation configuration.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "awair",
|
||||
"name": "Awair",
|
||||
"codeowners": ["@ahayworth", "@ricohageman"],
|
||||
"codeowners": ["@ahayworth", "@danielsjf"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/awair",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No actions defined
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: |
|
||||
We fetch both user and devices, could probably slow one down
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
data_description fields are missing
|
||||
Should not abort in cloud step when anything else than invalid access token
|
||||
Find out why access token is optional
|
||||
Discovered devices step is redundant
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
Move happy flow to the top and merge with `test_show_form`
|
||||
Reuse `result`
|
||||
Cloud tests should initialize with data directly
|
||||
Tests should finish in CREATE_ENTRY
|
||||
dependency-transparency:
|
||||
status: todo
|
||||
comment: |
|
||||
Dependency is not built in the CI
|
||||
docs-actions: todo
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: No explicit event subscription
|
||||
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:
|
||||
status: exempt
|
||||
comment: No actions defined
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
Patch objects where we use them
|
||||
Use test helpers to load JSON
|
||||
typo `no_devicess_fixture`
|
||||
Make common config entries for cloud and local
|
||||
Test setup of the integration
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: done
|
||||
comment: |
|
||||
Can move to shorthand attribute
|
||||
Can remove typecast
|
||||
diagnostics: todo
|
||||
discovery: done
|
||||
discovery-update-info: done
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class:
|
||||
status: done
|
||||
comment: |
|
||||
Can remove rounding
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -21,29 +21,29 @@ from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import DOMAIN
|
||||
from .websocket import BeoWebsocket
|
||||
from .websocket import BangOlufsenWebsocket
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeoData:
|
||||
class BangOlufsenData:
|
||||
"""Dataclass for API client and WebSocket client."""
|
||||
|
||||
websocket: BeoWebsocket
|
||||
websocket: BangOlufsenWebsocket
|
||||
client: MozartClient
|
||||
|
||||
|
||||
type BeoConfigEntry = ConfigEntry[BeoData]
|
||||
type BangOlufsenConfigEntry = ConfigEntry[BangOlufsenData]
|
||||
|
||||
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) -> bool:
|
||||
"""Set up from a config entry."""
|
||||
|
||||
# Remove casts to str
|
||||
assert entry.unique_id
|
||||
|
||||
# Create device now as BeoWebsocket needs a device for debug logging, firing events etc.
|
||||
# Create device now as BangOlufsenWebsocket needs a device for debug logging, firing events etc.
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
@@ -68,10 +68,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
await client.close_api_client()
|
||||
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error
|
||||
|
||||
websocket = BeoWebsocket(hass, entry, client)
|
||||
websocket = BangOlufsenWebsocket(hass, entry, client)
|
||||
|
||||
# Add the websocket and API client
|
||||
entry.runtime_data = BeoData(websocket, client)
|
||||
entry.runtime_data = BangOlufsenData(websocket, client)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -82,7 +82,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: BangOlufsenConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
# Close the API client and WebSocket notification listener
|
||||
entry.runtime_data.client.disconnect_notifications()
|
||||
|
||||
@@ -47,7 +47,7 @@ _exception_map = {
|
||||
}
|
||||
|
||||
|
||||
class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
|
||||
_beolink_jid = ""
|
||||
|
||||
@@ -14,25 +14,21 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
|
||||
|
||||
class BeoSource:
|
||||
class BangOlufsenSource:
|
||||
"""Class used for associating device source ids with friendly names. May not include all sources."""
|
||||
|
||||
DEEZER: Final[Source] = Source(name="Deezer", id="deezer")
|
||||
LINE_IN: Final[Source] = Source(name="Line-In", id="lineIn")
|
||||
NET_RADIO: Final[Source] = Source(name="B&O Radio", id="netRadio")
|
||||
SPDIF: Final[Source] = Source(name="Optical", id="spdif")
|
||||
TIDAL: Final[Source] = Source(name="Tidal", id="tidal")
|
||||
UNKNOWN: Final[Source] = Source(name="Unknown Source", id="unknown")
|
||||
URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer")
|
||||
|
||||
|
||||
BEO_STATES: dict[str, MediaPlayerState] = {
|
||||
BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = {
|
||||
# Dict used for translating device states to Home Assistant states.
|
||||
"started": MediaPlayerState.PLAYING,
|
||||
"buffering": MediaPlayerState.PLAYING,
|
||||
"idle": MediaPlayerState.IDLE,
|
||||
"paused": MediaPlayerState.PAUSED,
|
||||
"stopped": MediaPlayerState.IDLE,
|
||||
"stopped": MediaPlayerState.PAUSED,
|
||||
"ended": MediaPlayerState.PAUSED,
|
||||
"error": MediaPlayerState.IDLE,
|
||||
# A device's initial state is "unknown" and should be treated as "idle"
|
||||
@@ -40,19 +36,19 @@ BEO_STATES: dict[str, MediaPlayerState] = {
|
||||
}
|
||||
|
||||
# Dict used for translating Home Assistant settings to device repeat settings.
|
||||
BEO_REPEAT_FROM_HA: dict[RepeatMode, str] = {
|
||||
BANG_OLUFSEN_REPEAT_FROM_HA: dict[RepeatMode, str] = {
|
||||
RepeatMode.ALL: "all",
|
||||
RepeatMode.ONE: "track",
|
||||
RepeatMode.OFF: "none",
|
||||
}
|
||||
# Dict used for translating device repeat settings to Home Assistant settings.
|
||||
BEO_REPEAT_TO_HA: dict[str, RepeatMode] = {
|
||||
value: key for key, value in BEO_REPEAT_FROM_HA.items()
|
||||
BANG_OLUFSEN_REPEAT_TO_HA: dict[str, RepeatMode] = {
|
||||
value: key for key, value in BANG_OLUFSEN_REPEAT_FROM_HA.items()
|
||||
}
|
||||
|
||||
|
||||
# Media types for play_media
|
||||
class BeoMediaType(StrEnum):
|
||||
class BangOlufsenMediaType(StrEnum):
|
||||
"""Bang & Olufsen specific media types."""
|
||||
|
||||
FAVOURITE = "favourite"
|
||||
@@ -63,7 +59,7 @@ class BeoMediaType(StrEnum):
|
||||
OVERLAY_TTS = "overlay_tts"
|
||||
|
||||
|
||||
class BeoModel(StrEnum):
|
||||
class BangOlufsenModel(StrEnum):
|
||||
"""Enum for compatible model names."""
|
||||
|
||||
# Mozart devices
|
||||
@@ -82,18 +78,8 @@ class BeoModel(StrEnum):
|
||||
BEOREMOTE_ONE = "Beoremote One"
|
||||
|
||||
|
||||
class BeoAttribute(StrEnum):
|
||||
"""Enum for extra_state_attribute keys."""
|
||||
|
||||
BEOLINK = "beolink"
|
||||
BEOLINK_PEERS = "peers"
|
||||
BEOLINK_SELF = "self"
|
||||
BEOLINK_LEADER = "leader"
|
||||
BEOLINK_LISTENERS = "listeners"
|
||||
|
||||
|
||||
# Physical "buttons" on devices
|
||||
class BeoButtons(StrEnum):
|
||||
class BangOlufsenButtons(StrEnum):
|
||||
"""Enum for device buttons."""
|
||||
|
||||
BLUETOOTH = "Bluetooth"
|
||||
@@ -140,7 +126,7 @@ class WebsocketNotification(StrEnum):
|
||||
DOMAIN: Final[str] = "bang_olufsen"
|
||||
|
||||
# Default values for configuration.
|
||||
DEFAULT_MODEL: Final[str] = BeoModel.BEOSOUND_BALANCE
|
||||
DEFAULT_MODEL: Final[str] = BangOlufsenModel.BEOSOUND_BALANCE
|
||||
|
||||
# Configuration.
|
||||
CONF_SERIAL_NUMBER: Final = "serial_number"
|
||||
@@ -148,7 +134,7 @@ CONF_BEOLINK_JID: Final = "jid"
|
||||
|
||||
# Models to choose from in manual configuration.
|
||||
SELECTABLE_MODELS: list[str] = [
|
||||
model.value for model in BeoModel if model != BeoModel.BEOREMOTE_ONE
|
||||
model.value for model in BangOlufsenModel if model != BangOlufsenModel.BEOREMOTE_ONE
|
||||
]
|
||||
|
||||
MANUFACTURER: Final[str] = "Bang & Olufsen"
|
||||
@@ -160,15 +146,15 @@ ATTR_ITEM_NUMBER: Final[str] = "in"
|
||||
ATTR_FRIENDLY_NAME: Final[str] = "fn"
|
||||
|
||||
# Power states.
|
||||
BEO_ON: Final[str] = "on"
|
||||
BANG_OLUFSEN_ON: Final[str] = "on"
|
||||
|
||||
VALID_MEDIA_TYPES: Final[tuple] = (
|
||||
BeoMediaType.FAVOURITE,
|
||||
BeoMediaType.DEEZER,
|
||||
BeoMediaType.RADIO,
|
||||
BeoMediaType.TTS,
|
||||
BeoMediaType.TIDAL,
|
||||
BeoMediaType.OVERLAY_TTS,
|
||||
BangOlufsenMediaType.FAVOURITE,
|
||||
BangOlufsenMediaType.DEEZER,
|
||||
BangOlufsenMediaType.RADIO,
|
||||
BangOlufsenMediaType.TTS,
|
||||
BangOlufsenMediaType.TIDAL,
|
||||
BangOlufsenMediaType.OVERLAY_TTS,
|
||||
MediaType.MUSIC,
|
||||
MediaType.URL,
|
||||
MediaType.CHANNEL,
|
||||
@@ -246,7 +232,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
)
|
||||
|
||||
# Device events
|
||||
BEO_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
|
||||
BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
|
||||
|
||||
# Dict used to translate native Bang & Olufsen event names to string.json compatible ones
|
||||
EVENT_TRANSLATION_MAP: dict[str, str] = {
|
||||
@@ -263,7 +249,7 @@ EVENT_TRANSLATION_MAP: dict[str, str] = {
|
||||
|
||||
CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS"
|
||||
|
||||
DEVICE_BUTTONS: Final[list[str]] = [x.value for x in BeoButtons]
|
||||
DEVICE_BUTTONS: Final[list[str]] = [x.value for x in BangOlufsenButtons]
|
||||
|
||||
|
||||
DEVICE_BUTTON_EVENTS: Final[list[str]] = [
|
||||
|
||||
@@ -10,13 +10,13 @@ from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import BeoConfigEntry
|
||||
from . import BangOlufsenConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .util import get_device_buttons
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: BeoConfigEntry
|
||||
hass: HomeAssistant, config_entry: BangOlufsenConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ from homeassistant.helpers.entity import Entity
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class BeoBase:
|
||||
"""Base class for Bang & Olufsen Home Assistant objects."""
|
||||
class BangOlufsenBase:
|
||||
"""Base class for BangOlufsen Home Assistant objects."""
|
||||
|
||||
def __init__(self, entry: ConfigEntry, client: MozartClient) -> None:
|
||||
"""Initialize the object."""
|
||||
@@ -51,8 +51,8 @@ class BeoBase:
|
||||
)
|
||||
|
||||
|
||||
class BeoEntity(Entity, BeoBase):
|
||||
"""Base Entity for Bang & Olufsen entities."""
|
||||
class BangOlufsenEntity(Entity, BangOlufsenBase):
|
||||
"""Base Entity for BangOlufsen entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BeoConfigEntry
|
||||
from . import BangOlufsenConfigEntry
|
||||
from .const import (
|
||||
BEO_REMOTE_CONTROL_KEYS,
|
||||
BEO_REMOTE_KEY_EVENTS,
|
||||
@@ -25,10 +25,10 @@ from .const import (
|
||||
DEVICE_BUTTON_EVENTS,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
BeoModel,
|
||||
BangOlufsenModel,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BeoEntity
|
||||
from .entity import BangOlufsenEntity
|
||||
from .util import get_device_buttons, get_remotes
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -36,14 +36,14 @@ PARALLEL_UPDATES = 0
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BeoConfigEntry,
|
||||
config_entry: BangOlufsenConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Event entities from config entry."""
|
||||
entities: list[BeoEvent] = []
|
||||
entities: list[BangOlufsenEvent] = []
|
||||
|
||||
async_add_entities(
|
||||
BeoButtonEvent(config_entry, button_type)
|
||||
BangOlufsenButtonEvent(config_entry, button_type)
|
||||
for button_type in get_device_buttons(config_entry.data[CONF_MODEL])
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ async def async_setup_entry(
|
||||
# Add Light keys
|
||||
entities.extend(
|
||||
[
|
||||
BeoRemoteKeyEvent(
|
||||
BangOlufsenRemoteKeyEvent(
|
||||
config_entry,
|
||||
remote,
|
||||
f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}",
|
||||
@@ -66,7 +66,7 @@ async def async_setup_entry(
|
||||
# Add Control keys
|
||||
entities.extend(
|
||||
[
|
||||
BeoRemoteKeyEvent(
|
||||
BangOlufsenRemoteKeyEvent(
|
||||
config_entry,
|
||||
remote,
|
||||
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}",
|
||||
@@ -84,9 +84,10 @@ async def async_setup_entry(
|
||||
config_entry.entry_id
|
||||
)
|
||||
for device in devices:
|
||||
if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in {
|
||||
remote.serial_number for remote in remotes
|
||||
}:
|
||||
if (
|
||||
device.model == BangOlufsenModel.BEOREMOTE_ONE
|
||||
and device.serial_number not in {remote.serial_number for remote in remotes}
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
@@ -94,13 +95,13 @@ async def async_setup_entry(
|
||||
async_add_entities(new_entities=entities)
|
||||
|
||||
|
||||
class BeoEvent(BeoEntity, EventEntity):
|
||||
class BangOlufsenEvent(BangOlufsenEntity, EventEntity):
|
||||
"""Base Event class."""
|
||||
|
||||
_attr_device_class = EventDeviceClass.BUTTON
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def __init__(self, config_entry: BeoConfigEntry) -> None:
|
||||
def __init__(self, config_entry: BangOlufsenConfigEntry) -> None:
|
||||
"""Initialize Event."""
|
||||
super().__init__(config_entry, config_entry.runtime_data.client)
|
||||
|
||||
@@ -111,12 +112,12 @@ class BeoEvent(BeoEntity, EventEntity):
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class BeoButtonEvent(BeoEvent):
|
||||
class BangOlufsenButtonEvent(BangOlufsenEvent):
|
||||
"""Event class for Button events."""
|
||||
|
||||
_attr_event_types = DEVICE_BUTTON_EVENTS
|
||||
|
||||
def __init__(self, config_entry: BeoConfigEntry, button_type: str) -> None:
|
||||
def __init__(self, config_entry: BangOlufsenConfigEntry, button_type: str) -> None:
|
||||
"""Initialize Button."""
|
||||
super().__init__(config_entry)
|
||||
|
||||
@@ -145,14 +146,14 @@ class BeoButtonEvent(BeoEvent):
|
||||
)
|
||||
|
||||
|
||||
class BeoRemoteKeyEvent(BeoEvent):
|
||||
class BangOlufsenRemoteKeyEvent(BangOlufsenEvent):
|
||||
"""Event class for Beoremote One key events."""
|
||||
|
||||
_attr_event_types = BEO_REMOTE_KEY_EVENTS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: BeoConfigEntry,
|
||||
config_entry: BangOlufsenConfigEntry,
|
||||
remote: PairedRemote,
|
||||
key_type: str,
|
||||
) -> None:
|
||||
@@ -165,8 +166,8 @@ class BeoRemoteKeyEvent(BeoEvent):
|
||||
self._attr_unique_id = f"{remote.serial_number}_{self._unique_id}_{key_type}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
|
||||
name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}",
|
||||
model=BeoModel.BEOREMOTE_ONE,
|
||||
name=f"{BangOlufsenModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}",
|
||||
model=BangOlufsenModel.BEOREMOTE_ONE,
|
||||
serial_number=remote.serial_number,
|
||||
sw_version=remote.app_version,
|
||||
manufacturer=MANUFACTURER,
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["mozart-api==5.3.1.108.0"],
|
||||
"requirements": ["mozart-api==5.1.0.247.1"],
|
||||
"zeroconf": ["_bangolufsen._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -69,11 +69,11 @@ from homeassistant.helpers.entity_platform import (
|
||||
)
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from . import BeoConfigEntry
|
||||
from . import BangOlufsenConfigEntry
|
||||
from .const import (
|
||||
BEO_REPEAT_FROM_HA,
|
||||
BEO_REPEAT_TO_HA,
|
||||
BEO_STATES,
|
||||
BANG_OLUFSEN_REPEAT_FROM_HA,
|
||||
BANG_OLUFSEN_REPEAT_TO_HA,
|
||||
BANG_OLUFSEN_STATES,
|
||||
BEOLINK_JOIN_SOURCES,
|
||||
BEOLINK_JOIN_SOURCES_TO_UPPER,
|
||||
CONF_BEOLINK_JID,
|
||||
@@ -82,12 +82,11 @@ from .const import (
|
||||
FALLBACK_SOURCES,
|
||||
MANUFACTURER,
|
||||
VALID_MEDIA_TYPES,
|
||||
BeoAttribute,
|
||||
BeoMediaType,
|
||||
BeoSource,
|
||||
BangOlufsenMediaType,
|
||||
BangOlufsenSource,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BeoEntity
|
||||
from .entity import BangOlufsenEntity
|
||||
from .util import get_serial_number_from_jid
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -96,7 +95,7 @@ SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
BEO_FEATURES = (
|
||||
BANG_OLUFSEN_FEATURES = (
|
||||
MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
|
||||
| MediaPlayerEntityFeature.GROUPING
|
||||
@@ -119,13 +118,15 @@ BEO_FEATURES = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BeoConfigEntry,
|
||||
config_entry: BangOlufsenConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a Media Player entity from config entry."""
|
||||
# Add MediaPlayer entity
|
||||
async_add_entities(
|
||||
new_entities=[BeoMediaPlayer(config_entry, config_entry.runtime_data.client)],
|
||||
new_entities=[
|
||||
BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client)
|
||||
],
|
||||
update_before_add=True,
|
||||
)
|
||||
|
||||
@@ -185,7 +186,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
"""Representation of a media player."""
|
||||
|
||||
_attr_name = None
|
||||
@@ -223,8 +224,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
# Beolink compatible sources
|
||||
self._beolink_sources: dict[str, bool] = {}
|
||||
self._remote_leader: BeolinkLeader | None = None
|
||||
# Extra state attributes:
|
||||
# Beolink: peer(s), listener(s), leader and self
|
||||
# Extra state attributes for showing Beolink: peer(s), listener(s), leader and self
|
||||
self._beolink_attributes: dict[str, dict[str, dict[str, str]]] = {}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
@@ -286,7 +286,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
queue_settings = await self._client.get_settings_queue(_request_timeout=5)
|
||||
|
||||
if queue_settings.repeat is not None:
|
||||
self._attr_repeat = BEO_REPEAT_TO_HA[queue_settings.repeat]
|
||||
self._attr_repeat = BANG_OLUFSEN_REPEAT_TO_HA[queue_settings.repeat]
|
||||
|
||||
if queue_settings.shuffle is not None:
|
||||
self._attr_shuffle = queue_settings.shuffle
|
||||
@@ -406,8 +406,8 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
|
||||
# Check if source is line-in or optical and progress should be updated
|
||||
if self._source_change.id in (
|
||||
BeoSource.LINE_IN.id,
|
||||
BeoSource.SPDIF.id,
|
||||
BangOlufsenSource.LINE_IN.id,
|
||||
BangOlufsenSource.SPDIF.id,
|
||||
):
|
||||
self._playback_progress = PlaybackProgress(progress=0)
|
||||
|
||||
@@ -436,10 +436,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
await self._async_update_beolink()
|
||||
|
||||
async def _async_update_beolink(self) -> None:
|
||||
"""Update the current Beolink leader, listeners, peers and self.
|
||||
|
||||
Updates Home Assistant state.
|
||||
"""
|
||||
"""Update the current Beolink leader, listeners, peers and self."""
|
||||
|
||||
self._beolink_attributes = {}
|
||||
|
||||
@@ -448,22 +445,18 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
|
||||
# Add Beolink self
|
||||
self._beolink_attributes = {
|
||||
BeoAttribute.BEOLINK: {
|
||||
BeoAttribute.BEOLINK_SELF: {self.device_entry.name: self._beolink_jid}
|
||||
}
|
||||
"beolink": {"self": {self.device_entry.name: self._beolink_jid}}
|
||||
}
|
||||
|
||||
# Add Beolink peers
|
||||
peers = await self._client.get_beolink_peers()
|
||||
|
||||
if len(peers) > 0:
|
||||
self._beolink_attributes[BeoAttribute.BEOLINK][
|
||||
BeoAttribute.BEOLINK_PEERS
|
||||
] = {}
|
||||
self._beolink_attributes["beolink"]["peers"] = {}
|
||||
for peer in peers:
|
||||
self._beolink_attributes[BeoAttribute.BEOLINK][
|
||||
BeoAttribute.BEOLINK_PEERS
|
||||
][peer.friendly_name] = peer.jid
|
||||
self._beolink_attributes["beolink"]["peers"][peer.friendly_name] = (
|
||||
peer.jid
|
||||
)
|
||||
|
||||
# Add Beolink listeners / leader
|
||||
self._remote_leader = self._playback_metadata.remote_leader
|
||||
@@ -484,9 +477,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
# Add self
|
||||
group_members.append(self.entity_id)
|
||||
|
||||
self._beolink_attributes[BeoAttribute.BEOLINK][
|
||||
BeoAttribute.BEOLINK_LEADER
|
||||
] = {
|
||||
self._beolink_attributes["beolink"]["leader"] = {
|
||||
self._remote_leader.friendly_name: self._remote_leader.jid,
|
||||
}
|
||||
|
||||
@@ -523,9 +514,9 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
beolink_listener.jid
|
||||
)
|
||||
break
|
||||
self._beolink_attributes[BeoAttribute.BEOLINK][
|
||||
BeoAttribute.BEOLINK_LISTENERS
|
||||
] = beolink_listeners_attribute
|
||||
self._beolink_attributes["beolink"]["listeners"] = (
|
||||
beolink_listeners_attribute
|
||||
)
|
||||
|
||||
self._attr_group_members = group_members
|
||||
|
||||
@@ -583,7 +574,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
for sound_mode in sound_modes:
|
||||
label = f"{sound_mode.name} ({sound_mode.id})"
|
||||
|
||||
self._sound_modes[label] = cast(int, sound_mode.id)
|
||||
self._sound_modes[label] = sound_mode.id
|
||||
|
||||
if sound_mode.id == active_sound_mode.id:
|
||||
self._attr_sound_mode = label
|
||||
@@ -596,7 +587,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
"""Flag media player features that are supported."""
|
||||
features = BEO_FEATURES
|
||||
features = BANG_OLUFSEN_FEATURES
|
||||
|
||||
# Add seeking if supported by the current source
|
||||
if self._source_change.is_seekable is True:
|
||||
@@ -607,7 +598,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
"""Return the current state of the media player."""
|
||||
return BEO_STATES[self._state]
|
||||
return BANG_OLUFSEN_STATES[self._state]
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
@@ -624,18 +615,11 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_content_type(self) -> MediaType | str | None:
|
||||
def media_content_type(self) -> str:
|
||||
"""Return the current media type."""
|
||||
content_type = {
|
||||
BeoSource.URI_STREAMER.id: MediaType.URL,
|
||||
BeoSource.DEEZER.id: BeoMediaType.DEEZER,
|
||||
BeoSource.TIDAL.id: BeoMediaType.TIDAL,
|
||||
BeoSource.NET_RADIO.id: BeoMediaType.RADIO,
|
||||
}
|
||||
# Hard to determine content type.
|
||||
if self._source_change.id in content_type:
|
||||
return content_type[self._source_change.id]
|
||||
|
||||
# Hard to determine content type
|
||||
if self._source_change.id == BangOlufsenSource.URI_STREAMER.id:
|
||||
return MediaType.URL
|
||||
return MediaType.MUSIC
|
||||
|
||||
@property
|
||||
@@ -648,11 +632,6 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
"""Return the current playback progress."""
|
||||
return self._playback_progress.progress
|
||||
|
||||
@property
|
||||
def media_content_id(self) -> str | None:
|
||||
"""Return internal ID of Deezer, Tidal and radio stations."""
|
||||
return self._playback_metadata.source_internal_id
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Return URL of the currently playing music."""
|
||||
@@ -761,7 +740,9 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||
"""Set playback queues to repeat."""
|
||||
await self._client.set_settings_queue(
|
||||
play_queue_settings=PlayQueueSettings(repeat=BEO_REPEAT_FROM_HA[repeat])
|
||||
play_queue_settings=PlayQueueSettings(
|
||||
repeat=BANG_OLUFSEN_REPEAT_FROM_HA[repeat]
|
||||
)
|
||||
)
|
||||
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
@@ -865,7 +846,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
self._volume.level.level + offset_volume, 100
|
||||
)
|
||||
|
||||
if media_type == BeoMediaType.OVERLAY_TTS:
|
||||
if media_type == BangOlufsenMediaType.OVERLAY_TTS:
|
||||
# Bang & Olufsen cloud TTS
|
||||
overlay_play_request.text_to_speech = (
|
||||
OverlayPlayRequestTextToSpeechTextToSpeech(
|
||||
@@ -882,14 +863,14 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
|
||||
# The "provider" media_type may not be suitable for overlay all the time.
|
||||
# Use it for now.
|
||||
elif media_type == BeoMediaType.TTS:
|
||||
elif media_type == BangOlufsenMediaType.TTS:
|
||||
await self._client.post_overlay_play(
|
||||
overlay_play_request=OverlayPlayRequest(
|
||||
uri=Uri(location=media_id),
|
||||
)
|
||||
)
|
||||
|
||||
elif media_type == BeoMediaType.RADIO:
|
||||
elif media_type == BangOlufsenMediaType.RADIO:
|
||||
await self._client.run_provided_scene(
|
||||
scene_properties=SceneProperties(
|
||||
action_list=[
|
||||
@@ -901,13 +882,13 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
)
|
||||
)
|
||||
|
||||
elif media_type == BeoMediaType.FAVOURITE:
|
||||
elif media_type == BangOlufsenMediaType.FAVOURITE:
|
||||
await self._client.activate_preset(id=int(media_id))
|
||||
|
||||
elif media_type in (BeoMediaType.DEEZER, BeoMediaType.TIDAL):
|
||||
elif media_type in (BangOlufsenMediaType.DEEZER, BangOlufsenMediaType.TIDAL):
|
||||
try:
|
||||
# Play Deezer flow.
|
||||
if media_id == "flow" and media_type == BeoMediaType.DEEZER:
|
||||
if media_id == "flow" and media_type == BangOlufsenMediaType.DEEZER:
|
||||
deezer_id = None
|
||||
|
||||
if "id" in kwargs[ATTR_MEDIA_EXTRA]:
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from .const import DEVICE_BUTTONS, DOMAIN, BeoButtons, BeoModel
|
||||
from .const import DEVICE_BUTTONS, DOMAIN, BangOlufsenButtons, BangOlufsenModel
|
||||
|
||||
|
||||
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
|
||||
@@ -40,27 +40,16 @@ async def get_remotes(client: MozartClient) -> list[PairedRemote]:
|
||||
]
|
||||
|
||||
|
||||
def get_device_buttons(model: BeoModel) -> list[str]:
|
||||
def get_device_buttons(model: BangOlufsenModel) -> list[str]:
|
||||
"""Get supported buttons for a given model."""
|
||||
# Beoconnect Core does not have any buttons
|
||||
if model == BeoModel.BEOCONNECT_CORE:
|
||||
return []
|
||||
|
||||
buttons = DEVICE_BUTTONS.copy()
|
||||
|
||||
# Models that don't have a microphone button
|
||||
if model in (
|
||||
BeoModel.BEOSOUND_A5,
|
||||
BeoModel.BEOSOUND_A9,
|
||||
BeoModel.BEOSOUND_PREMIERE,
|
||||
):
|
||||
buttons.remove(BeoButtons.MICROPHONE)
|
||||
# Beosound Premiere does not have a bluetooth button
|
||||
if model == BangOlufsenModel.BEOSOUND_PREMIERE:
|
||||
buttons.remove(BangOlufsenButtons.BLUETOOTH)
|
||||
|
||||
# Models that don't have a Bluetooth button
|
||||
if model in (
|
||||
BeoModel.BEOSOUND_A9,
|
||||
BeoModel.BEOSOUND_PREMIERE,
|
||||
):
|
||||
buttons.remove(BeoButtons.BLUETOOTH)
|
||||
# Beoconnect Core does not have any buttons
|
||||
elif model == BangOlufsenModel.BEOCONNECT_CORE:
|
||||
buttons = []
|
||||
|
||||
return buttons
|
||||
|
||||
@@ -27,20 +27,20 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from .const import (
|
||||
BEO_WEBSOCKET_EVENT,
|
||||
BANG_OLUFSEN_WEBSOCKET_EVENT,
|
||||
CONNECTION_STATUS,
|
||||
DOMAIN,
|
||||
EVENT_TRANSLATION_MAP,
|
||||
BeoModel,
|
||||
BangOlufsenModel,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BeoBase
|
||||
from .entity import BangOlufsenBase
|
||||
from .util import get_device, get_remotes
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BeoWebsocket(BeoBase):
|
||||
class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
"""The WebSocket listeners."""
|
||||
|
||||
def __init__(
|
||||
@@ -48,7 +48,7 @@ class BeoWebsocket(BeoBase):
|
||||
) -> None:
|
||||
"""Initialize the WebSocket listeners."""
|
||||
|
||||
BeoBase.__init__(self, entry, client)
|
||||
BangOlufsenBase.__init__(self, entry, client)
|
||||
|
||||
self.hass = hass
|
||||
self._device = get_device(hass, self._unique_id)
|
||||
@@ -178,7 +178,7 @@ class BeoWebsocket(BeoBase):
|
||||
self.entry.entry_id
|
||||
)
|
||||
if device.serial_number is not None
|
||||
and device.model == BeoModel.BEOREMOTE_ONE
|
||||
and device.model == BangOlufsenModel.BEOREMOTE_ONE
|
||||
]
|
||||
# Get paired remotes from device
|
||||
remote_serial_numbers = [
|
||||
@@ -274,4 +274,4 @@ class BeoWebsocket(BeoBase):
|
||||
}
|
||||
|
||||
_LOGGER.debug("%s", debug_notification)
|
||||
self.hass.bus.async_fire(BEO_WEBSOCKET_EVENT, debug_notification)
|
||||
self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, debug_notification)
|
||||
|
||||
@@ -174,13 +174,5 @@
|
||||
"on": "mdi:window-open"
|
||||
}
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"occupancy_cleared": {
|
||||
"trigger": "mdi:home-outline"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"trigger": "mdi:home"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description_occupancy": "The behavior of the targeted occupancy sensors to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"condition_type": {
|
||||
"is_bat_low": "{entity_name} battery is low",
|
||||
@@ -321,36 +317,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Binary sensor",
|
||||
"triggers": {
|
||||
"occupancy_cleared": {
|
||||
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
|
||||
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Occupancy cleared"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
|
||||
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Occupancy detected"
|
||||
}
|
||||
}
|
||||
"title": "Binary sensor"
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Provides triggers for binary sensors."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from . import DOMAIN, BinarySensorDeviceClass
|
||||
|
||||
|
||||
def get_device_class_or_undefined(
|
||||
hass: HomeAssistant, entity_id: str
|
||||
) -> str | None | UndefinedType:
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
try:
|
||||
return get_device_class(hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
class BinarySensorOnOffTrigger(EntityStateTriggerBase):
|
||||
"""Class for binary sensor on/off triggers."""
|
||||
|
||||
_device_class: BinarySensorDeviceClass | None
|
||||
_domain: str = DOMAIN
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if get_device_class_or_undefined(self._hass, entity_id)
|
||||
== self._device_class
|
||||
}
|
||||
|
||||
|
||||
def make_binary_sensor_trigger(
|
||||
device_class: BinarySensorDeviceClass | None,
|
||||
to_state: str,
|
||||
) -> type[BinarySensorOnOffTrigger]:
|
||||
"""Create an entity state trigger class."""
|
||||
|
||||
class CustomTrigger(BinarySensorOnOffTrigger):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_device_class = device_class
|
||||
_to_state = to_state
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"occupancy_detected": make_binary_sensor_trigger(
|
||||
BinarySensorDeviceClass.OCCUPANCY, STATE_ON
|
||||
),
|
||||
"occupancy_cleared": make_binary_sensor_trigger(
|
||||
BinarySensorDeviceClass.OCCUPANCY, STATE_OFF
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for binary sensors."""
|
||||
return TRIGGERS
|
||||
@@ -1,25 +0,0 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
occupancy_cleared:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
device_class: occupancy
|
||||
|
||||
occupancy_detected:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
device_class: occupancy
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/blink",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["blinkpy"],
|
||||
"requirements": ["blinkpy==0.25.1"]
|
||||
"requirements": ["blinkpy==0.24.1"]
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==2.0.0",
|
||||
"bleak==1.0.1",
|
||||
"bleak-retry-connector==4.4.3",
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.8.0"
|
||||
"habluetooth==5.7.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""The bluetooth_tracker component."""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Constants for the Bluetooth Tracker component."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "bluetooth_tracker"
|
||||
SERVICE_UPDATE: Final = "update"
|
||||
|
||||
BT_PREFIX: Final = "BT_"
|
||||
CONF_REQUEST_RSSI: Final = "request_rssi"
|
||||
DEFAULT_DEVICE_ID: Final = -1
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Tracking for bluetooth devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
import bluetooth
|
||||
from bt_proximity import BluetoothRSSI
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_tracker import (
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_TRACK_NEW,
|
||||
DEFAULT_TRACK_NEW,
|
||||
PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA,
|
||||
SCAN_INTERVAL,
|
||||
SourceType,
|
||||
)
|
||||
from homeassistant.components.device_tracker.legacy import (
|
||||
YAML_DEVICES,
|
||||
AsyncSeeCallback,
|
||||
Device,
|
||||
async_load_config,
|
||||
)
|
||||
from homeassistant.const import CONF_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import (
|
||||
BT_PREFIX,
|
||||
CONF_REQUEST_RSSI,
|
||||
DEFAULT_DEVICE_ID,
|
||||
DOMAIN,
|
||||
SERVICE_UPDATE,
|
||||
)
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
PLATFORM_SCHEMA: Final = DEVICE_TRACKER_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_TRACK_NEW): cv.boolean,
|
||||
vol.Optional(CONF_REQUEST_RSSI): cv.boolean,
|
||||
vol.Optional(CONF_DEVICE_ID, default=DEFAULT_DEVICE_ID): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=-1)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_bluetooth_device(device: Device) -> bool:
|
||||
"""Check whether a device is a bluetooth device by its mac."""
|
||||
return device.mac is not None and device.mac[:3].upper() == BT_PREFIX
|
||||
|
||||
|
||||
def discover_devices(device_id: int) -> list[tuple[str, str]]:
|
||||
"""Discover Bluetooth devices."""
|
||||
try:
|
||||
result = bluetooth.discover_devices(
|
||||
duration=8,
|
||||
lookup_names=True,
|
||||
flush_cache=True,
|
||||
lookup_class=False,
|
||||
device_id=device_id,
|
||||
)
|
||||
except OSError as ex:
|
||||
# OSError is generally thrown if a bluetooth device isn't found
|
||||
_LOGGER.error("Couldn't discover bluetooth devices: %s", ex)
|
||||
return []
|
||||
_LOGGER.debug("Bluetooth devices discovered = %d", len(result))
|
||||
return result # type: ignore[no-any-return]
|
||||
|
||||
|
||||
async def see_device(
|
||||
hass: HomeAssistant,
|
||||
async_see: AsyncSeeCallback,
|
||||
mac: str,
|
||||
device_name: str,
|
||||
rssi: tuple[int] | None = None,
|
||||
) -> None:
|
||||
"""Mark a device as seen."""
|
||||
attributes = {}
|
||||
if rssi is not None:
|
||||
attributes["rssi"] = rssi
|
||||
|
||||
await async_see(
|
||||
mac=f"{BT_PREFIX}{mac}",
|
||||
host_name=device_name,
|
||||
attributes=attributes,
|
||||
source_type=SourceType.BLUETOOTH,
|
||||
)
|
||||
|
||||
|
||||
async def get_tracking_devices(hass: HomeAssistant) -> tuple[set[str], set[str]]:
|
||||
"""Load all known devices.
|
||||
|
||||
We just need the devices so set consider_home and home range to 0
|
||||
"""
|
||||
yaml_path: str = hass.config.path(YAML_DEVICES)
|
||||
|
||||
devices = await async_load_config(yaml_path, hass, timedelta(0))
|
||||
bluetooth_devices = [device for device in devices if is_bluetooth_device(device)]
|
||||
|
||||
devices_to_track: set[str] = {
|
||||
device.mac[3:]
|
||||
for device in bluetooth_devices
|
||||
if device.track and device.mac is not None
|
||||
}
|
||||
devices_to_not_track: set[str] = {
|
||||
device.mac[3:]
|
||||
for device in bluetooth_devices
|
||||
if not device.track and device.mac is not None
|
||||
}
|
||||
|
||||
return devices_to_track, devices_to_not_track
|
||||
|
||||
|
||||
def lookup_name(mac: str) -> str | None:
|
||||
"""Lookup a Bluetooth device name."""
|
||||
_LOGGER.debug("Scanning %s", mac)
|
||||
return bluetooth.lookup_name(mac, timeout=5) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
async def async_setup_scanner(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_see: AsyncSeeCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> bool:
|
||||
"""Set up the Bluetooth Scanner."""
|
||||
device_id: int = config[CONF_DEVICE_ID]
|
||||
interval: timedelta = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
|
||||
request_rssi: bool = config.get(CONF_REQUEST_RSSI, False)
|
||||
update_bluetooth_lock = asyncio.Lock()
|
||||
|
||||
# If track new devices is true discover new devices on startup.
|
||||
track_new: bool = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW)
|
||||
_LOGGER.debug("Tracking new devices is set to %s", track_new)
|
||||
|
||||
devices_to_track, devices_to_not_track = await get_tracking_devices(hass)
|
||||
|
||||
if not devices_to_track and not track_new:
|
||||
_LOGGER.debug("No Bluetooth devices to track and not tracking new devices")
|
||||
|
||||
if request_rssi:
|
||||
_LOGGER.debug("Detecting RSSI for devices")
|
||||
|
||||
async def perform_bluetooth_update() -> None:
|
||||
"""Discover Bluetooth devices and update status."""
|
||||
_LOGGER.debug("Performing Bluetooth devices discovery and update")
|
||||
tasks: list[asyncio.Task[None]] = []
|
||||
|
||||
try:
|
||||
if track_new:
|
||||
devices = await hass.async_add_executor_job(discover_devices, device_id)
|
||||
for mac, _device_name in devices:
|
||||
if mac not in devices_to_track and mac not in devices_to_not_track:
|
||||
devices_to_track.add(mac)
|
||||
|
||||
for mac in devices_to_track:
|
||||
friendly_name = await hass.async_add_executor_job(lookup_name, mac)
|
||||
if friendly_name is None:
|
||||
# Could not lookup device name
|
||||
continue
|
||||
|
||||
rssi = None
|
||||
if request_rssi:
|
||||
client = BluetoothRSSI(mac)
|
||||
rssi = await hass.async_add_executor_job(client.request_rssi)
|
||||
client.close()
|
||||
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
see_device(hass, async_see, mac, friendly_name, rssi)
|
||||
)
|
||||
)
|
||||
|
||||
if tasks:
|
||||
await asyncio.wait(tasks)
|
||||
|
||||
except bluetooth.BluetoothError:
|
||||
_LOGGER.exception("Error looking up Bluetooth device")
|
||||
|
||||
async def update_bluetooth(now: datetime | None = None) -> None:
|
||||
"""Lookup Bluetooth devices and update status."""
|
||||
# If an update is in progress, we don't do anything
|
||||
if update_bluetooth_lock.locked():
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"Previous execution of update_bluetooth is taking longer than the"
|
||||
" scheduled update of interval %s"
|
||||
),
|
||||
interval,
|
||||
)
|
||||
return
|
||||
|
||||
async with update_bluetooth_lock:
|
||||
await perform_bluetooth_update()
|
||||
|
||||
async def handle_manual_update_bluetooth(call: ServiceCall) -> None:
|
||||
"""Update bluetooth devices on demand."""
|
||||
await update_bluetooth()
|
||||
|
||||
hass.async_create_task(update_bluetooth())
|
||||
async_track_time_interval(hass, update_bluetooth, interval)
|
||||
|
||||
hass.services.async_register(DOMAIN, SERVICE_UPDATE, handle_manual_update_bluetooth)
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"services": {
|
||||
"update": {
|
||||
"service": "mdi:update"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "bluetooth_tracker",
|
||||
"name": "Bluetooth Tracker",
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bluetooth_tracker",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bluetooth", "bt_proximity"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["bt-proximity==0.2.1", "PyBluez==0.22"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
update:
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"services": {
|
||||
"update": {
|
||||
"description": "Triggers manual tracker update.",
|
||||
"name": "Update"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
|
||||
config_entry_id=entry.entry_id,
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
|
||||
identifiers={(DOMAIN, entry.unique_id or entry.entry_id)},
|
||||
name=f"Bosch {panel.model.name}",
|
||||
name=f"Bosch {panel.model}",
|
||||
manufacturer="Bosch Security Systems",
|
||||
model=panel.model.name,
|
||||
model=panel.model,
|
||||
sw_version=panel.firmware_version,
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user