mirror of
https://github.com/home-assistant/core.git
synced 2026-03-17 16:32:04 +01:00
Compare commits
5 Commits
gha-builde
...
state_attr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
113921df1b | ||
|
|
9f1e8ed07d | ||
|
|
06c5f3d3d1 | ||
|
|
c9537f7357 | ||
|
|
57a33dd34d |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -16,7 +16,6 @@ Dockerfile.dev linguist-language=Dockerfile
|
||||
CODEOWNERS linguist-generated=true
|
||||
Dockerfile linguist-generated=true
|
||||
homeassistant/generated/*.py linguist-generated=true
|
||||
machine/* linguist-generated=true
|
||||
mypy.ini linguist-generated=true
|
||||
requirements.txt linguist-generated=true
|
||||
requirements_all.txt linguist-generated=true
|
||||
|
||||
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@@ -18,11 +18,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
|
||||
|
||||
## Testing
|
||||
|
||||
When writing or modifying tests, ensure all test function parameters have type annotations.
|
||||
Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
|
||||
|
||||
## Good practices
|
||||
|
||||
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
|
||||
|
||||
240
.github/workflows/builder.yml
vendored
240
.github/workflows/builder.yml
vendored
@@ -35,7 +35,6 @@ jobs:
|
||||
channel: ${{ steps.version.outputs.channel }}
|
||||
publish: ${{ steps.version.outputs.publish }}
|
||||
architectures: ${{ env.ARCHITECTURES }}
|
||||
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -73,10 +72,45 @@ jobs:
|
||||
- name: Download Translations
|
||||
run: python3 -m script.translations download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
- name: Archive translations
|
||||
shell: bash
|
||||
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
build_base:
|
||||
name: Build ${{ matrix.arch }} base core image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-latest
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: steps.version.outputs.channel == 'dev'
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -87,7 +121,7 @@ jobs:
|
||||
name: wheels
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: steps.version.outputs.channel == 'dev'
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -97,12 +131,18 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
name: package
|
||||
|
||||
- name: Set up Python
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Adjust nightly version
|
||||
if: steps.version.outputs.channel == 'dev'
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
shell: bash
|
||||
env:
|
||||
UV_PRERELEASE: allow
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
python3 -m pip install "$(grep '^uv' < requirements.txt)"
|
||||
uv pip install packaging tomli
|
||||
@@ -140,72 +180,92 @@ jobs:
|
||||
sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
|
||||
fi
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
- name: Extract translations
|
||||
run: |
|
||||
tar xvf translations.tar.gz
|
||||
rm translations.tar.gz
|
||||
|
||||
- name: Write meta info file
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Upload build context overlay
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
name: build-context
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
homeassistant/components/*/translations/
|
||||
rootfs/OFFICIAL_IMAGE
|
||||
home_assistant_frontend-*.whl
|
||||
home_assistant_intents-*.whl
|
||||
homeassistant/const.py
|
||||
homeassistant/components/frontend/manifest.json
|
||||
homeassistant/components/conversation/manifest.json
|
||||
homeassistant/package_constraints.txt
|
||||
requirements_all.txt
|
||||
requirements.txt
|
||||
pyproject.toml
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build_base:
|
||||
name: Build ${{ matrix.arch }} base core image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Download build context overlay
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: build-context
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build variables
|
||||
id: vars
|
||||
shell: bash
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
echo "base_image=ghcr.io/home-assistant/${ARCH}-homeassistant-base:${BASE_IMAGE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "cache_image=ghcr.io/home-assistant/${ARCH}-homeassistant:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify base image signature
|
||||
env:
|
||||
BASE_IMAGE: ${{ steps.vars.outputs.base_image }}
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
|
||||
"${BASE_IMAGE}"
|
||||
|
||||
- name: Verify cache image signature
|
||||
id: cache
|
||||
continue-on-error: true
|
||||
env:
|
||||
CACHE_IMAGE: ${{ steps.vars.outputs.cache_image }}
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
|
||||
"${CACHE_IMAGE}"
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder/actions/build-image@gha-builder # zizmor: ignore[unpinned-uses]
|
||||
id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
|
||||
cache-gha: false
|
||||
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: .
|
||||
cosign-base-identity: "https://github.com/home-assistant/docker/.*"
|
||||
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
|
||||
image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant
|
||||
image-tags: ${{ needs.init.outputs.version }}
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ steps.vars.outputs.platform }}
|
||||
push: true
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
cache-from: ${{ steps.cache.outcome == 'success' && steps.vars.outputs.cache_image || '' }}
|
||||
build-args: |
|
||||
BUILD_FROM=${{ steps.vars.outputs.base_image }}
|
||||
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
|
||||
labels: |
|
||||
io.hass.arch=${{ matrix.arch }}
|
||||
io.hass.version=${{ needs.init.outputs.version }}
|
||||
org.opencontainers.image.created=${{ steps.vars.outputs.created }}
|
||||
org.opencontainers.image.version=${{ needs.init.outputs.version }}
|
||||
|
||||
- name: Sign image
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
cosign sign --yes "ghcr.io/home-assistant/${ARCH}-homeassistant:${VERSION}@${DIGEST}"
|
||||
|
||||
build_machine:
|
||||
name: Build ${{ matrix.machine }} machine core image
|
||||
@@ -254,38 +314,35 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Compute extra tags
|
||||
id: tags
|
||||
shell: bash
|
||||
- name: Set build additional args
|
||||
env:
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
# Create general tags
|
||||
if [[ "${VERSION}" =~ d ]]; then
|
||||
echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
|
||||
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
|
||||
elif [[ "${VERSION}" =~ b ]]; then
|
||||
echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
|
||||
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
|
||||
else
|
||||
echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Build machine image
|
||||
uses: home-assistant/builder/actions/build-image@gha-builder # zizmor: ignore[unpinned-uses]
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
cache-gha: false
|
||||
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: machine/
|
||||
cosign-base-identity: "https://github.com/home-assistant/core/.*"
|
||||
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
file: machine/${{ matrix.machine }}
|
||||
image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
|
||||
image-tags: |
|
||||
${{ needs.init.outputs.version }}
|
||||
${{ steps.tags.outputs.extra_tags }}
|
||||
push: true
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
|
||||
with:
|
||||
image: ${{ matrix.arch }}
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
--target /data/machine \
|
||||
--cosign \
|
||||
--machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}"
|
||||
|
||||
publish_ha:
|
||||
name: Publish version files
|
||||
@@ -343,7 +400,7 @@ jobs:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
@@ -385,7 +442,7 @@ jobs:
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -399,7 +456,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -485,10 +542,15 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Download build context overlay
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: build-context
|
||||
name: translations
|
||||
|
||||
- name: Extract translations
|
||||
run: |
|
||||
tar xvf translations.tar.gz
|
||||
rm translations.tar.gz
|
||||
|
||||
- name: Build package
|
||||
shell: bash
|
||||
@@ -530,7 +592,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -543,7 +605,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -552,7 +614,7 @@ jobs:
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
@@ -852,6 +852,10 @@ jobs:
|
||||
needs:
|
||||
- info
|
||||
- base
|
||||
- gen-requirements-all
|
||||
- hassfest
|
||||
- prek
|
||||
- mypy
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
@@ -1396,7 +1400,7 @@ jobs:
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
pytest-partial:
|
||||
name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
|
||||
@@ -1566,7 +1570,7 @@ jobs:
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
upload-test-results:
|
||||
name: Upload test results to Codecov
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -58,8 +58,8 @@ jobs:
|
||||
# v1.7.0
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
|
||||
with:
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }}
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }}
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -33,6 +33,6 @@ jobs:
|
||||
|
||||
- name: Upload Translations
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
run: |
|
||||
python3 -m script.translations upload
|
||||
|
||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
exclude_types: [csv, json, html]
|
||||
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.23.1
|
||||
rev: v1.22.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
|
||||
@@ -570,7 +570,6 @@ homeassistant.components.trafikverket_train.*
|
||||
homeassistant.components.trafikverket_weatherstation.*
|
||||
homeassistant.components.transmission.*
|
||||
homeassistant.components.trend.*
|
||||
homeassistant.components.trmnl.*
|
||||
homeassistant.components.tts.*
|
||||
homeassistant.components.twentemilieu.*
|
||||
homeassistant.components.unifi.*
|
||||
|
||||
@@ -15,11 +15,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
|
||||
|
||||
## Testing
|
||||
|
||||
When writing or modifying tests, ensure all test function parameters have type annotations.
|
||||
Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
|
||||
|
||||
## Good practices
|
||||
|
||||
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
|
||||
|
||||
14
CODEOWNERS
generated
14
CODEOWNERS
generated
@@ -186,8 +186,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/auth/ @home-assistant/core
|
||||
/homeassistant/components/automation/ @home-assistant/core
|
||||
/tests/components/automation/ @home-assistant/core
|
||||
/homeassistant/components/autoskope/ @mcisk
|
||||
/tests/components/autoskope/ @mcisk
|
||||
/homeassistant/components/avea/ @pattyland
|
||||
/homeassistant/components/awair/ @ahayworth @ricohageman
|
||||
/tests/components/awair/ @ahayworth @ricohageman
|
||||
@@ -974,8 +972,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/logbook/ @home-assistant/core
|
||||
/homeassistant/components/logger/ @home-assistant/core
|
||||
/tests/components/logger/ @home-assistant/core
|
||||
/homeassistant/components/lojack/ @devinslick
|
||||
/tests/components/lojack/ @devinslick
|
||||
/homeassistant/components/london_underground/ @jpbede
|
||||
/tests/components/london_underground/ @jpbede
|
||||
/homeassistant/components/lookin/ @ANMalko @bdraco
|
||||
@@ -1075,8 +1071,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/moon/ @fabaff @frenck
|
||||
/homeassistant/components/mopeka/ @bdraco
|
||||
/tests/components/mopeka/ @bdraco
|
||||
/homeassistant/components/motion/ @home-assistant/core
|
||||
/tests/components/motion/ @home-assistant/core
|
||||
/homeassistant/components/motion_blinds/ @starkillerOG
|
||||
/tests/components/motion_blinds/ @starkillerOG
|
||||
/homeassistant/components/motionblinds_ble/ @LennP @jerrybboy
|
||||
@@ -1190,8 +1184,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/nzbget/ @chriscla
|
||||
/homeassistant/components/obihai/ @dshokouhi @ejpenney
|
||||
/tests/components/obihai/ @dshokouhi @ejpenney
|
||||
/homeassistant/components/occupancy/ @home-assistant/core
|
||||
/tests/components/occupancy/ @home-assistant/core
|
||||
/homeassistant/components/octoprint/ @rfleming71
|
||||
/tests/components/octoprint/ @rfleming71
|
||||
/homeassistant/components/ohmconnect/ @robbiet480
|
||||
@@ -1772,8 +1764,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/trend/ @jpbede
|
||||
/homeassistant/components/triggercmd/ @rvmey
|
||||
/tests/components/triggercmd/ @rvmey
|
||||
/homeassistant/components/trmnl/ @joostlek
|
||||
/tests/components/trmnl/ @joostlek
|
||||
/homeassistant/components/tts/ @home-assistant/core
|
||||
/tests/components/tts/ @home-assistant/core
|
||||
/homeassistant/components/tuya/ @Tuya @zlinoliver
|
||||
@@ -1790,8 +1780,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ukraine_alarm/ @PaulAnnekov
|
||||
/homeassistant/components/unifi/ @Kane610
|
||||
/tests/components/unifi/ @Kane610
|
||||
/homeassistant/components/unifi_access/ @imhotep @RaHehl
|
||||
/tests/components/unifi_access/ @imhotep @RaHehl
|
||||
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
|
||||
/homeassistant/components/unifiled/ @florisvdk
|
||||
/homeassistant/components/unifiprotect/ @RaHehl
|
||||
@@ -1917,8 +1905,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/wiffi/ @mampfes
|
||||
/homeassistant/components/wilight/ @leofig-rj
|
||||
/tests/components/wilight/ @leofig-rj
|
||||
/homeassistant/components/window/ @home-assistant/core
|
||||
/tests/components/window/ @home-assistant/core
|
||||
/homeassistant/components/wirelesstag/ @sergeymaysak
|
||||
/homeassistant/components/withings/ @joostlek
|
||||
/tests/components/withings/ @joostlek
|
||||
|
||||
1
Dockerfile
generated
1
Dockerfile
generated
@@ -10,6 +10,7 @@ LABEL \
|
||||
org.opencontainers.image.description="Open-source home automation platform running on Python 3" \
|
||||
org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \
|
||||
org.opencontainers.image.licenses="Apache-2.0" \
|
||||
org.opencontainers.image.source="https://github.com/home-assistant/core" \
|
||||
org.opencontainers.image.title="Home Assistant" \
|
||||
org.opencontainers.image.url="https://www.home-assistant.io/"
|
||||
|
||||
|
||||
@@ -245,9 +245,6 @@ DEFAULT_INTEGRATIONS = {
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidity",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"window",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
# These integrations are set up if recovery mode is activated.
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
{
|
||||
"domain": "ubiquiti",
|
||||
"name": "Ubiquiti",
|
||||
"integrations": [
|
||||
"airos",
|
||||
"unifi",
|
||||
"unifi_access",
|
||||
"unifi_direct",
|
||||
"unifiled",
|
||||
"unifiprotect"
|
||||
]
|
||||
"integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
EntityStateConditionBase,
|
||||
@@ -44,7 +43,7 @@ def make_entity_state_required_features_condition(
|
||||
class CustomCondition(EntityStateRequiredFeaturesCondition):
|
||||
"""Condition for entity state changes."""
|
||||
|
||||
_domain_specs = {domain: DomainSpec()}
|
||||
_domain = domain
|
||||
_states = {to_state}
|
||||
_required_features = required_features
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.entity import get_supported_features
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityTargetStateTriggerBase,
|
||||
@@ -45,7 +44,7 @@ def make_entity_state_trigger_required_features(
|
||||
class CustomTrigger(EntityStateTriggerRequiredFeatures):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domain_specs = {domain: DomainSpec()}
|
||||
_domains = {domain}
|
||||
_to_states = {to_state}
|
||||
_required_features = required_features
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Defines a base Alexa Devices entity."""
|
||||
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_DEVICE_TYPE
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -24,15 +25,20 @@ class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._serial_num = serial_num
|
||||
model = self.device.model
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial_num)},
|
||||
name=self.device.account_name,
|
||||
model=self.device.model,
|
||||
model=model,
|
||||
model_id=self.device.device_type,
|
||||
manufacturer=self.device.manufacturer or "Amazon",
|
||||
hw_version=self.device.hardware_version,
|
||||
sw_version=self.device.software_version,
|
||||
serial_number=serial_num,
|
||||
sw_version=(
|
||||
self.device.software_version
|
||||
if model != SPEAKER_GROUP_DEVICE_TYPE
|
||||
else None
|
||||
),
|
||||
serial_number=serial_num if model != SPEAKER_GROUP_DEVICE_TYPE else None,
|
||||
)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{serial_num}-{description.key}"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.0.1"]
|
||||
"requirements": ["aioamazondevices==13.0.0"]
|
||||
}
|
||||
|
||||
@@ -101,10 +101,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
assert method is not None
|
||||
|
||||
await method(self.device, state)
|
||||
self.coordinator.data[self.device.serial_number].sensors[
|
||||
self.entity_description.key
|
||||
].value = state
|
||||
self.async_write_ha_state()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
|
||||
@@ -338,7 +338,6 @@ class Analytics:
|
||||
|
||||
hass = self._hass
|
||||
supervisor_info = None
|
||||
addons_info: dict[str, Any] | None = None
|
||||
operating_system_info: dict[str, Any] = {}
|
||||
|
||||
if self._data.uuid is None:
|
||||
@@ -348,7 +347,6 @@ class Analytics:
|
||||
if self.supervisor:
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
operating_system_info = hassio.get_os_info(hass) or {}
|
||||
addons_info = hassio.get_addons_info(hass) or {}
|
||||
|
||||
system_info = await async_get_system_info(hass)
|
||||
integrations = []
|
||||
@@ -421,10 +419,13 @@ class Analytics:
|
||||
|
||||
integrations.append(integration.domain)
|
||||
|
||||
if addons_info is not None:
|
||||
if supervisor_info is not None:
|
||||
supervisor_client = hassio.get_supervisor_client(hass)
|
||||
installed_addons = await asyncio.gather(
|
||||
*(supervisor_client.addons.addon_info(slug) for slug in addons_info)
|
||||
*(
|
||||
supervisor_client.addons.addon_info(addon[ATTR_SLUG])
|
||||
for addon in supervisor_info[ATTR_ADDONS]
|
||||
)
|
||||
)
|
||||
addons.extend(
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRunti
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Arcam binary sensors for incoming stream info."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ArcamFmjConfigEntry
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ArcamFmjBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes an Arcam FMJ binary sensor entity."""
|
||||
|
||||
value_fn: Callable[[State], bool | None]
|
||||
|
||||
|
||||
BINARY_SENSORS: tuple[ArcamFmjBinarySensorEntityDescription, ...] = (
|
||||
ArcamFmjBinarySensorEntityDescription(
|
||||
key="incoming_video_interlaced",
|
||||
translation_key="incoming_video_interlaced",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda state: (
|
||||
vp.interlaced
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ArcamFmjConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Arcam FMJ binary sensors from a config entry."""
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
entities: list[ArcamFmjBinarySensorEntity] = []
|
||||
for coordinator in coordinators.values():
|
||||
entities.extend(
|
||||
ArcamFmjBinarySensorEntity(coordinator, description)
|
||||
for description in BINARY_SENSORS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ArcamFmjBinarySensorEntity(ArcamFmjEntity, BinarySensorEntity):
|
||||
"""Representation of an Arcam FMJ binary sensor."""
|
||||
|
||||
entity_description: ArcamFmjBinarySensorEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the binary sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.state)
|
||||
@@ -66,7 +66,6 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
model="Arcam FMJ AVR",
|
||||
name=name,
|
||||
)
|
||||
self.zone_unique_id = f"{unique_id}-{zone}"
|
||||
|
||||
if zone != 1:
|
||||
self.device_info["via_device"] = (DOMAIN, unique_id)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Base entity for Arcam FMJ integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ArcamFmjCoordinator
|
||||
|
||||
|
||||
class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
|
||||
"""Base entity for Arcam FMJ."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ArcamFmjCoordinator,
|
||||
description: EntityDescription | None = None,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = coordinator.device_info
|
||||
self._attr_entity_registry_enabled_default = coordinator.state.zn == 1
|
||||
self._attr_unique_id = coordinator.zone_unique_id
|
||||
if description is not None:
|
||||
self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
|
||||
self.entity_description = description
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"incoming_video_interlaced": {
|
||||
"default": "mdi:reorder-horizontal"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"incoming_audio_config": {
|
||||
"default": "mdi:surround-sound"
|
||||
},
|
||||
"incoming_audio_format": {
|
||||
"default": "mdi:dolby"
|
||||
},
|
||||
"incoming_audio_sample_rate": {
|
||||
"default": "mdi:waveform"
|
||||
},
|
||||
"incoming_video_aspect_ratio": {
|
||||
"default": "mdi:aspect-ratio"
|
||||
},
|
||||
"incoming_video_colorspace": {
|
||||
"default": "mdi:palette"
|
||||
},
|
||||
"incoming_video_horizontal_resolution": {
|
||||
"default": "mdi:arrow-expand-horizontal"
|
||||
},
|
||||
"incoming_video_refresh_rate": {
|
||||
"default": "mdi:animation"
|
||||
},
|
||||
"incoming_video_vertical_resolution": {
|
||||
"default": "mdi:arrow-expand-vertical"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@ from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import EVENT_TURN_ON
|
||||
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +39,14 @@ async def async_setup_entry(
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
async_add_entities(
|
||||
[ArcamFmj(coordinators[zone]) for zone in (1, 2)],
|
||||
[
|
||||
ArcamFmj(
|
||||
config_entry.title,
|
||||
coordinators[zone],
|
||||
config_entry.unique_id or config_entry.entry_id,
|
||||
)
|
||||
for zone in (1, 2)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -60,13 +67,21 @@ def convert_exception[**_P, _R](
|
||||
return _convert_exception
|
||||
|
||||
|
||||
class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
|
||||
class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
|
||||
"""Representation of a media device."""
|
||||
|
||||
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_name: str,
|
||||
coordinator: ArcamFmjCoordinator,
|
||||
uuid: str,
|
||||
) -> None:
|
||||
"""Initialize device."""
|
||||
super().__init__(coordinator)
|
||||
self._state = coordinator.state
|
||||
self._attr_name = f"Zone {self._state.zn}"
|
||||
self._attr_supported_features = (
|
||||
MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
@@ -79,6 +94,9 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
|
||||
)
|
||||
if self._state.zn == 1:
|
||||
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
self._attr_unique_id = f"{uuid}-{self._state.zn}"
|
||||
self._attr_entity_registry_enabled_default = self._state.zn == 1
|
||||
self._attr_device_info = coordinator.device_info
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Arcam sensors for incoming stream info."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace
|
||||
from arcam.fmj.state import IncomingAudioConfig, IncomingAudioFormat, State
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfFrequency
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ArcamFmjConfigEntry
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ArcamFmjSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes an Arcam FMJ sensor entity."""
|
||||
|
||||
value_fn: Callable[[State], int | float | str | None]
|
||||
|
||||
|
||||
SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_horizontal_resolution",
|
||||
translation_key="incoming_video_horizontal_resolution",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement="px",
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
vp.horizontal_resolution
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_vertical_resolution",
|
||||
translation_key="incoming_video_vertical_resolution",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement="px",
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
vp.vertical_resolution
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_refresh_rate",
|
||||
translation_key="incoming_video_refresh_rate",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
vp.refresh_rate
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_aspect_ratio",
|
||||
translation_key="incoming_video_aspect_ratio",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingVideoAspectRatio],
|
||||
value_fn=lambda state: (
|
||||
vp.aspect_ratio.name.lower()
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_colorspace",
|
||||
translation_key="incoming_video_colorspace",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingVideoColorspace],
|
||||
value_fn=lambda state: (
|
||||
vp.colorspace.name.lower()
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_audio_format",
|
||||
translation_key="incoming_audio_format",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingAudioFormat],
|
||||
value_fn=lambda state: (
|
||||
result.name.lower()
|
||||
if (result := state.get_incoming_audio_format()[0]) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_audio_config",
|
||||
translation_key="incoming_audio_config",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingAudioConfig],
|
||||
value_fn=lambda state: (
|
||||
result.name.lower()
|
||||
if (result := state.get_incoming_audio_format()[1]) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_audio_sample_rate",
|
||||
translation_key="incoming_audio_sample_rate",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
None
|
||||
if (sample_rate := state.get_incoming_audio_sample_rate()) == 0
|
||||
else sample_rate
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ArcamFmjConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Arcam FMJ sensors from a config entry."""
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
entities: list[ArcamFmjSensorEntity] = []
|
||||
for coordinator in coordinators.values():
|
||||
entities.extend(
|
||||
ArcamFmjSensorEntity(coordinator, description) for description in SENSORS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ArcamFmjSensorEntity(ArcamFmjEntity, SensorEntity):
|
||||
"""Representation of an Arcam FMJ sensor."""
|
||||
|
||||
entity_description: ArcamFmjSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | str | None:
|
||||
"""Return the sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.state)
|
||||
@@ -23,121 +23,5 @@
|
||||
"trigger_type": {
|
||||
"turn_on": "{entity_name} was requested to turn on"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"incoming_video_interlaced": {
|
||||
"name": "Incoming video interlaced"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"incoming_audio_config": {
|
||||
"name": "Incoming audio configuration",
|
||||
"state": {
|
||||
"auro_10_1": "Auro 10.1",
|
||||
"auro_11_1": "Auro 11.1",
|
||||
"auro_13_1": "Auro 13.1",
|
||||
"auro_2_2_2": "Auro 2.2.2",
|
||||
"auro_5_0": "Auro 5.0",
|
||||
"auro_5_1": "Auro 5.1",
|
||||
"auro_8_0": "Auro 8.0",
|
||||
"auro_9_1": "Auro 9.1",
|
||||
"auro_quad": "Auro quad",
|
||||
"dual_mono": "Dual mono",
|
||||
"dual_mono_lfe": "Dual mono + LFE",
|
||||
"mono": "Mono",
|
||||
"mono_lfe": "Mono + LFE",
|
||||
"stereo_center": "Stereo center",
|
||||
"stereo_center_lfe": "Stereo center + LFE",
|
||||
"stereo_center_surr_lr": "Stereo center surround L/R",
|
||||
"stereo_center_surr_lr_back_lr": "Stereo center surround L/R back L/R",
|
||||
"stereo_center_surr_lr_back_lr_lfe": "Stereo center surround L/R back L/R + LFE",
|
||||
"stereo_center_surr_lr_back_matrix": "Stereo center surround L/R back matrix",
|
||||
"stereo_center_surr_lr_back_matrix_lfe": "Stereo center surround L/R back matrix + LFE",
|
||||
"stereo_center_surr_lr_back_mono": "Stereo center surround L/R back mono",
|
||||
"stereo_center_surr_lr_back_mono_lfe": "Stereo center surround L/R back mono + LFE",
|
||||
"stereo_center_surr_lr_lfe": "Stereo center surround L/R + LFE",
|
||||
"stereo_center_surr_mono": "Stereo center surround mono",
|
||||
"stereo_center_surr_mono_lfe": "Stereo center surround mono + LFE",
|
||||
"stereo_downmix": "Stereo downmix",
|
||||
"stereo_downmix_lfe": "Stereo downmix + LFE",
|
||||
"stereo_lfe": "Stereo + LFE",
|
||||
"stereo_only": "Stereo only",
|
||||
"stereo_only_lo_ro": "Stereo only Lo/Ro",
|
||||
"stereo_only_lo_ro_lfe": "Stereo only Lo/Ro + LFE",
|
||||
"stereo_surr_lr": "Stereo surround L/R",
|
||||
"stereo_surr_lr_back_lr": "Stereo surround L/R back L/R",
|
||||
"stereo_surr_lr_back_lr_lfe": "Stereo surround L/R back L/R + LFE",
|
||||
"stereo_surr_lr_back_matrix": "Stereo surround L/R back matrix",
|
||||
"stereo_surr_lr_back_matrix_lfe": "Stereo surround L/R back matrix + LFE",
|
||||
"stereo_surr_lr_back_mono": "Stereo surround L/R back mono",
|
||||
"stereo_surr_lr_back_mono_lfe": "Stereo surround L/R back mono + LFE",
|
||||
"stereo_surr_lr_lfe": "Stereo surround L/R + LFE",
|
||||
"stereo_surr_mono": "Stereo surround mono",
|
||||
"stereo_surr_mono_lfe": "Stereo surround mono + LFE",
|
||||
"undetected": "Undetected",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
},
|
||||
"incoming_audio_format": {
|
||||
"name": "Incoming audio format",
|
||||
"state": {
|
||||
"analogue_direct": "Analogue direct",
|
||||
"auro_3d": "Auro-3D",
|
||||
"dolby_atmos": "Dolby Atmos",
|
||||
"dolby_digital": "Dolby Digital",
|
||||
"dolby_digital_ex": "Dolby Digital EX",
|
||||
"dolby_digital_plus": "Dolby Digital Plus",
|
||||
"dolby_digital_surround": "Dolby Digital Surround",
|
||||
"dolby_digital_true_hd": "Dolby TrueHD",
|
||||
"dts": "DTS",
|
||||
"dts_96_24": "DTS 96/24",
|
||||
"dts_core": "DTS Core",
|
||||
"dts_es_discrete": "DTS-ES Discrete",
|
||||
"dts_es_discrete_96_24": "DTS-ES Discrete 96/24",
|
||||
"dts_es_matrix": "DTS-ES Matrix",
|
||||
"dts_es_matrix_96_24": "DTS-ES Matrix 96/24",
|
||||
"dts_hd_high_res_audio": "DTS-HD High Resolution Audio",
|
||||
"dts_hd_master_audio": "DTS-HD Master Audio",
|
||||
"dts_low_bit_rate": "DTS Low Bit Rate",
|
||||
"dts_x": "DTS:X",
|
||||
"imax_enhanced": "IMAX Enhanced",
|
||||
"pcm": "PCM",
|
||||
"pcm_zero": "PCM zero",
|
||||
"undetected": "Undetected",
|
||||
"unsupported": "Unsupported"
|
||||
}
|
||||
},
|
||||
"incoming_audio_sample_rate": {
|
||||
"name": "Incoming audio sample rate"
|
||||
},
|
||||
"incoming_video_aspect_ratio": {
|
||||
"name": "Incoming video aspect ratio",
|
||||
"state": {
|
||||
"aspect_16_9": "16:9",
|
||||
"aspect_4_3": "4:3",
|
||||
"undefined": "Undefined"
|
||||
}
|
||||
},
|
||||
"incoming_video_colorspace": {
|
||||
"name": "Incoming video colorspace",
|
||||
"state": {
|
||||
"dolby_vision": "Dolby Vision",
|
||||
"hdr10": "HDR10",
|
||||
"hdr10_plus": "HDR10+",
|
||||
"hlg": "HLG",
|
||||
"normal": "Normal"
|
||||
}
|
||||
},
|
||||
"incoming_video_horizontal_resolution": {
|
||||
"name": "Incoming video horizontal resolution"
|
||||
},
|
||||
"incoming_video_refresh_rate": {
|
||||
"name": "Incoming video refresh rate"
|
||||
},
|
||||
"incoming_video_vertical_resolution": {
|
||||
"name": "Incoming video vertical resolution"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,13 +78,19 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
|
||||
index: int = 0,
|
||||
) -> None:
|
||||
"""Initialize a pipeline selector."""
|
||||
if index >= 1:
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"pipeline_{index + 1}",
|
||||
translation_key="pipeline_n",
|
||||
translation_placeholders={"index": str(index + 1)},
|
||||
)
|
||||
if index < 1:
|
||||
# Keep compatibility
|
||||
key_suffix = ""
|
||||
placeholder = ""
|
||||
else:
|
||||
key_suffix = f"_{index + 1}"
|
||||
placeholder = f" {index + 1}"
|
||||
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"pipeline{key_suffix}",
|
||||
translation_placeholders={"index": placeholder},
|
||||
)
|
||||
|
||||
self._domain = domain
|
||||
self._unique_id_prefix = unique_id_prefix
|
||||
|
||||
@@ -7,17 +7,11 @@
|
||||
},
|
||||
"select": {
|
||||
"pipeline": {
|
||||
"name": "Assistant",
|
||||
"name": "Assistant{index}",
|
||||
"state": {
|
||||
"preferred": "Preferred"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "Assistant {index}",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "Finished speaking detection",
|
||||
"state": {
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from aiohttp import ClientError
|
||||
from aiohttp import ClientResponseError
|
||||
from yalexs.exceptions import AugustApiAIOHTTPError
|
||||
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
from yalexs.manager.gateway import Config as YaleXSConfig
|
||||
@@ -13,12 +13,7 @@ from yalexs.manager.gateway import Config as YaleXSConfig
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
@@ -50,18 +45,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bo
|
||||
august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session)
|
||||
try:
|
||||
await async_setup_august(hass, entry, august_gateway)
|
||||
except OAuth2TokenRequestReauthError as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except (RequireValidation, InvalidAuth) as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except TimeoutError as err:
|
||||
raise ConfigEntryNotReady("Timed out connecting to august api") from err
|
||||
except (
|
||||
AugustApiAIOHTTPError,
|
||||
OAuth2TokenRequestError,
|
||||
ClientError,
|
||||
CannotConnect,
|
||||
) as err:
|
||||
except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
@@ -121,7 +121,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"climate",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"humidifier",
|
||||
@@ -153,8 +152,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"person",
|
||||
"remote",
|
||||
"scene",
|
||||
@@ -164,7 +161,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"text",
|
||||
"update",
|
||||
"vacuum",
|
||||
"window",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""The Autoskope integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
from autoskope_client.api import AutoskopeApi
|
||||
from autoskope_client.models import CannotConnect, InvalidAuth
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import DEFAULT_HOST
|
||||
from .coordinator import AutoskopeConfigEntry, AutoskopeDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) -> bool:
|
||||
"""Set up Autoskope from a config entry."""
|
||||
session = async_create_clientsession(hass, cookie_jar=aiohttp.CookieJar())
|
||||
|
||||
api = AutoskopeApi(
|
||||
host=entry.data.get(CONF_HOST, DEFAULT_HOST),
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=session,
|
||||
)
|
||||
|
||||
try:
|
||||
await api.connect()
|
||||
except InvalidAuth as err:
|
||||
# Raise ConfigEntryError until reauth flow is implemented (then ConfigEntryAuthFailed)
|
||||
raise ConfigEntryError(
|
||||
"Authentication failed, please check credentials"
|
||||
) from err
|
||||
except CannotConnect as err:
|
||||
raise ConfigEntryNotReady("Could not connect to Autoskope API") from err
|
||||
|
||||
coordinator = AutoskopeDataUpdateCoordinator(hass, api, 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: AutoskopeConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Config flow for the Autoskope integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from autoskope_client.api import AutoskopeApi
|
||||
from autoskope_client.models import CannotConnect, InvalidAuth
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.data_entry_flow import section
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import DEFAULT_HOST, DOMAIN, SECTION_ADVANCED_SETTINGS
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=DEFAULT_HOST): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.URL)
|
||||
),
|
||||
}
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Autoskope."""
|
||||
|
||||
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:
|
||||
username = user_input[CONF_USERNAME].lower()
|
||||
host = user_input[SECTION_ADVANCED_SETTINGS][CONF_HOST].lower()
|
||||
|
||||
try:
|
||||
cv.url(host)
|
||||
except vol.Invalid:
|
||||
errors["base"] = "invalid_url"
|
||||
|
||||
if not errors:
|
||||
await self.async_set_unique_id(f"{username}@{host}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
try:
|
||||
async with AutoskopeApi(
|
||||
host=host,
|
||||
username=username,
|
||||
password=user_input[CONF_PASSWORD],
|
||||
):
|
||||
pass
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"Autoskope ({username})",
|
||||
data={
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_HOST: host,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Constants for the Autoskope integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "autoskope"
|
||||
|
||||
DEFAULT_HOST = "https://portal.autoskope.de"
|
||||
SECTION_ADVANCED_SETTINGS = "advanced_settings"
|
||||
UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Data update coordinator for the Autoskope integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from autoskope_client.api import AutoskopeApi
|
||||
from autoskope_client.models import CannotConnect, InvalidAuth, Vehicle
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, UPDATE_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type AutoskopeConfigEntry = ConfigEntry[AutoskopeDataUpdateCoordinator]
|
||||
|
||||
|
||||
class AutoskopeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]):
|
||||
"""Class to manage fetching Autoskope data."""
|
||||
|
||||
config_entry: AutoskopeConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, api: AutoskopeApi, entry: AutoskopeConfigEntry
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
config_entry=entry,
|
||||
)
|
||||
self.api = api
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Vehicle]:
|
||||
"""Fetch data from API endpoint."""
|
||||
try:
|
||||
vehicles = await self.api.get_vehicles()
|
||||
return {vehicle.id: vehicle for vehicle in vehicles}
|
||||
|
||||
except InvalidAuth:
|
||||
# Attempt to re-authenticate using stored credentials
|
||||
try:
|
||||
await self.api.authenticate()
|
||||
# Retry the request after successful re-authentication
|
||||
vehicles = await self.api.get_vehicles()
|
||||
return {vehicle.id: vehicle for vehicle in vehicles}
|
||||
except InvalidAuth as reauth_err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed: {reauth_err}"
|
||||
) from reauth_err
|
||||
|
||||
except CannotConnect as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Support for Autoskope device tracking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from autoskope_client.constants import MANUFACTURER
|
||||
from autoskope_client.models import Vehicle
|
||||
|
||||
from homeassistant.components.device_tracker import SourceType, TrackerEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AutoskopeConfigEntry, AutoskopeDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AutoskopeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Autoskope device tracker entities."""
|
||||
coordinator: AutoskopeDataUpdateCoordinator = entry.runtime_data
|
||||
tracked_vehicles: set[str] = set()
|
||||
|
||||
@callback
|
||||
def update_entities() -> None:
|
||||
"""Update entities based on coordinator data."""
|
||||
current_vehicles = set(coordinator.data.keys())
|
||||
vehicles_to_add = current_vehicles - tracked_vehicles
|
||||
|
||||
if vehicles_to_add:
|
||||
new_entities = [
|
||||
AutoskopeDeviceTracker(coordinator, vehicle_id)
|
||||
for vehicle_id in vehicles_to_add
|
||||
]
|
||||
tracked_vehicles.update(vehicles_to_add)
|
||||
async_add_entities(new_entities)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(update_entities))
|
||||
update_entities()
|
||||
|
||||
|
||||
class AutoskopeDeviceTracker(
|
||||
CoordinatorEntity[AutoskopeDataUpdateCoordinator], TrackerEntity
|
||||
):
|
||||
"""Representation of an Autoskope tracked device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name: str | None = None
|
||||
|
||||
def __init__(
|
||||
self, coordinator: AutoskopeDataUpdateCoordinator, vehicle_id: str
|
||||
) -> None:
|
||||
"""Initialize the TrackerEntity."""
|
||||
super().__init__(coordinator)
|
||||
self._vehicle_id = vehicle_id
|
||||
self._attr_unique_id = vehicle_id
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
if (
|
||||
self._vehicle_id in self.coordinator.data
|
||||
and (device_entry := self.device_entry) is not None
|
||||
and device_entry.name != self._vehicle_data.name
|
||||
):
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, name=self._vehicle_data.name
|
||||
)
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device info for the vehicle."""
|
||||
vehicle = self.coordinator.data[self._vehicle_id]
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, str(vehicle.id))},
|
||||
name=vehicle.name,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=vehicle.model,
|
||||
serial_number=vehicle.imei,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data is not None
|
||||
and self._vehicle_id in self.coordinator.data
|
||||
)
|
||||
|
||||
@property
|
||||
def _vehicle_data(self) -> Vehicle:
|
||||
"""Return the vehicle data for the current entity."""
|
||||
return self.coordinator.data[self._vehicle_id]
|
||||
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return latitude value of the device."""
|
||||
if (vehicle := self._vehicle_data) and vehicle.position:
|
||||
return float(vehicle.position.latitude)
|
||||
return None
|
||||
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return longitude value of the device."""
|
||||
if (vehicle := self._vehicle_data) and vehicle.position:
|
||||
return float(vehicle.position.longitude)
|
||||
return None
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the source type of the device."""
|
||||
return SourceType.GPS
|
||||
|
||||
@property
|
||||
def location_accuracy(self) -> float:
|
||||
"""Return the location accuracy of the device in meters."""
|
||||
if (vehicle := self._vehicle_data) and vehicle.gps_quality:
|
||||
if vehicle.gps_quality > 0:
|
||||
# HDOP to estimated accuracy in meters
|
||||
# HDOP of 1-2 = good (5-10m), 2-5 = moderate (10-25m), >5 = poor (>25m)
|
||||
return float(max(5, int(vehicle.gps_quality * 5.0)))
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Return the icon based on the vehicle's activity."""
|
||||
if self._vehicle_id not in self.coordinator.data:
|
||||
return "mdi:car-clock"
|
||||
vehicle = self._vehicle_data
|
||||
if vehicle.position:
|
||||
if vehicle.position.park_mode:
|
||||
return "mdi:car-brake-parking"
|
||||
if vehicle.position.speed > 5: # Moving threshold: 5 km/h
|
||||
return "mdi:car-arrow-right"
|
||||
return "mdi:car"
|
||||
return "mdi:car-clock"
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "autoskope",
|
||||
"name": "Autoskope",
|
||||
"codeowners": ["@mcisk"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/autoskope",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["autoskope_client==1.4.1"]
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
# + in comment indicates requirement for quality scale
|
||||
# - in comment indicates issue to be fixed, not impacting quality scale
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not provide custom services.
|
||||
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 services.
|
||||
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: |
|
||||
Integration does not provide custom services.
|
||||
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:
|
||||
status: todo
|
||||
comment: |
|
||||
Reauthentication flow removed for initial PR, will be added in follow-up.
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not use discovery. Autoskope devices use NB-IoT/LTE-M (via IoT SIMs) and LoRaWAN.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not use discovery. Autoskope devices use NB-IoT/LTE-M (via IoT SIMs) and LoRaWAN.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
Only one entity type (device_tracker) is created, making this not applicable.
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
Reconfiguration flow removed for initial PR, will be added in follow-up.
|
||||
repair-issues: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing:
|
||||
status: todo
|
||||
comment: |
|
||||
Integration needs to be added to .strict-typing file for full compliance.
|
||||
@@ -1,52 +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%]",
|
||||
"invalid_url": "Invalid URL",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The password for your Autoskope account.",
|
||||
"username": "The username for your Autoskope account."
|
||||
},
|
||||
"description": "Enter your Autoskope credentials.",
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"data": {
|
||||
"host": "API endpoint"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The URL of your Autoskope API endpoint. Only change this if you use a white-label portal."
|
||||
},
|
||||
"name": "Advanced settings"
|
||||
}
|
||||
},
|
||||
"title": "Connect to Autoskope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"cannot_connect": {
|
||||
"description": "Home Assistant could not connect to the Autoskope API at {host}. Please check the connection details and ensure the API endpoint is reachable.\n\nError: {error}",
|
||||
"title": "Failed to connect to Autoskope"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"description": "Authentication with Autoskope failed for user {username}. Please re-authenticate the integration with the correct password.",
|
||||
"title": "Invalid Autoskope authentication"
|
||||
},
|
||||
"low_battery": {
|
||||
"description": "The battery voltage for vehicle {vehicle_name} ({vehicle_id}) is low ({value}V). Consider checking or replacing the battery.",
|
||||
"title": "Low vehicle battery ({vehicle_name})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,6 @@ class CreateBackupStage(StrEnum):
|
||||
ADDONS = "addons"
|
||||
AWAIT_ADDON_RESTARTS = "await_addon_restarts"
|
||||
DOCKER_CONFIG = "docker_config"
|
||||
CLEANING_UP = "cleaning_up"
|
||||
FINISHING_FILE = "finishing_file"
|
||||
FOLDERS = "folders"
|
||||
HOME_ASSISTANT = "home_assistant"
|
||||
@@ -1291,13 +1290,6 @@ class BackupManager:
|
||||
)
|
||||
# delete old backups more numerous than copies
|
||||
# try this regardless of agent errors above
|
||||
self.async_on_backup_event(
|
||||
CreateBackupEvent(
|
||||
reason=None,
|
||||
stage=CreateBackupStage.CLEANING_UP,
|
||||
state=CreateBackupState.IN_PROGRESS,
|
||||
)
|
||||
)
|
||||
await delete_backups_exceeding_configured_count(self)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.10.2"
|
||||
"habluetooth==5.9.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA,
|
||||
EntityTriggerBase,
|
||||
@@ -15,7 +14,7 @@ from . import DOMAIN
|
||||
class ButtonPressedTrigger(EntityTriggerBase):
|
||||
"""Trigger for button entity presses."""
|
||||
|
||||
_domain_specs = {DOMAIN: DomainSpec()}
|
||||
_domains = {DOMAIN}
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Diagnostics support for Chess.com."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import ChessConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ChessConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"player": asdict(coordinator.data.player),
|
||||
"stats": asdict(coordinator.data.stats),
|
||||
}
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Can't detect a game
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Provides conditions for climates."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
make_entity_state_attribute_condition,
|
||||
make_entity_state_condition,
|
||||
)
|
||||
|
||||
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
|
||||
@@ -19,14 +22,14 @@ CONDITIONS: dict[str, type[Condition]] = {
|
||||
HVACMode.HEAT_COOL,
|
||||
},
|
||||
),
|
||||
"is_cooling": make_entity_state_condition(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
|
||||
"is_cooling": make_entity_state_attribute_condition(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
),
|
||||
"is_drying": make_entity_state_condition(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
|
||||
"is_drying": make_entity_state_attribute_condition(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
),
|
||||
"is_heating": make_entity_state_condition(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
|
||||
"is_heating": make_entity_state_attribute_condition(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@ import voluptuous as vol
|
||||
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
EntityTargetStateTriggerBase,
|
||||
Trigger,
|
||||
TriggerConfig,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_trigger,
|
||||
make_entity_numerical_state_attribute_changed_trigger,
|
||||
make_entity_numerical_state_attribute_crossed_threshold_trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
@@ -35,7 +35,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
|
||||
class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domain_specs = {DOMAIN: DomainSpec()}
|
||||
_domains = {DOMAIN}
|
||||
_schema = HVAC_MODE_CHANGED_TRIGGER_SCHEMA
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
@@ -46,23 +46,23 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"hvac_mode_changed": HVACModeChangedTrigger,
|
||||
"started_cooling": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
|
||||
"started_cooling": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
),
|
||||
"started_drying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
),
|
||||
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
|
||||
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
|
||||
),
|
||||
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
|
||||
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
|
||||
),
|
||||
"target_temperature_changed": make_entity_numerical_state_changed_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
|
||||
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
|
||||
),
|
||||
"target_temperature_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
|
||||
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
|
||||
),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
|
||||
"turned_on": make_entity_transition_trigger(
|
||||
@@ -79,8 +79,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
HVACMode.HEAT_COOL,
|
||||
},
|
||||
),
|
||||
"started_heating": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
|
||||
"started_heating": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -516,8 +516,6 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
hass_info: dict[str, Any],
|
||||
domains_info: dict[str, dict[str, str]],
|
||||
) -> str:
|
||||
cloud = hass.data[DATA_CLOUD]
|
||||
|
||||
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
|
||||
if len(domain_info) == 0:
|
||||
return "No information available\n"
|
||||
@@ -574,15 +572,6 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
"</details>\n\n"
|
||||
)
|
||||
|
||||
# Add stored latency response if available
|
||||
if locations := cloud.remote.latency_by_location:
|
||||
markdown += "## Latency by location\n\n"
|
||||
markdown += "Location | Latency (ms)\n"
|
||||
markdown += "--- | ---\n"
|
||||
for location in sorted(locations):
|
||||
markdown += f"{location} | {locations[location]['avg'] or 'N/A'}\n"
|
||||
markdown += "\n"
|
||||
|
||||
# Add installed packages section
|
||||
try:
|
||||
installed_packages = await async_get_installed_packages()
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==2.0.0", "openai==2.21.0"],
|
||||
"requirements": ["hass-nabucasa==1.15.0", "openai==2.21.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.1"]
|
||||
"requirements": ["aiocomelit==2.0.0"]
|
||||
}
|
||||
|
||||
@@ -153,8 +153,8 @@ def websocket_get_entities(
|
||||
{
|
||||
vol.Required("type"): "config/entity_registry/update",
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
vol.Optional("aliases"): [vol.Any(str, None)],
|
||||
# If passed in, we update value. Passing None will remove old value.
|
||||
vol.Optional("aliases"): list,
|
||||
vol.Optional("area_id"): vol.Any(str, None),
|
||||
# Categories is a mapping of key/value (scope/category_id) pairs.
|
||||
# If passed in, we update/adjust only the provided scope(s).
|
||||
@@ -225,15 +225,10 @@ def websocket_update_entity(
|
||||
changes[key] = msg[key]
|
||||
|
||||
if "aliases" in msg:
|
||||
# Sanitize aliases by removing:
|
||||
# - Trailing and leading whitespace characters in the individual aliases
|
||||
# Create a set for the aliases without:
|
||||
# - Empty strings
|
||||
changes["aliases"] = aliases = []
|
||||
for alias in msg["aliases"]:
|
||||
if alias is None:
|
||||
aliases.append(er.COMPUTED_NAME)
|
||||
elif alias := alias.strip():
|
||||
aliases.append(alias)
|
||||
# - Trailing and leading whitespace characters in the individual aliases
|
||||
changes["aliases"] = {s_strip for s in msg["aliases"] if (s_strip := s.strip())}
|
||||
|
||||
if "labels" in msg:
|
||||
# Convert labels to a set
|
||||
|
||||
@@ -992,11 +992,18 @@ class DefaultAgent(ConversationEntity):
|
||||
continue
|
||||
context[attr] = state.attributes[attr]
|
||||
|
||||
entity_entry = entity_registry.async_get(state.entity_id)
|
||||
for name in intent.async_get_entity_aliases(
|
||||
self.hass, entity_entry, state=state
|
||||
):
|
||||
yield (name, name, context)
|
||||
if (
|
||||
entity := entity_registry.async_get(state.entity_id)
|
||||
) and entity.aliases:
|
||||
for alias in entity.aliases:
|
||||
alias = alias.strip()
|
||||
if not alias:
|
||||
continue
|
||||
|
||||
yield (alias, alias, context)
|
||||
|
||||
# Default name
|
||||
yield (state.name, state.name, context)
|
||||
|
||||
def _recognize_strict(
|
||||
self,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Provides conditions for covers."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.helpers.condition import Condition, EntityConditionBase
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
from .models import CoverDomainSpec
|
||||
|
||||
|
||||
class CoverConditionBase(EntityConditionBase[CoverDomainSpec]):
|
||||
"""Base condition for cover state checks."""
|
||||
|
||||
def is_valid_state(self, entity_state: State) -> bool:
|
||||
"""Check if the state matches the expected cover state."""
|
||||
domain_spec = self._domain_specs[split_entity_id(entity_state.entity_id)[0]]
|
||||
if domain_spec.value_source is not None:
|
||||
return (
|
||||
entity_state.attributes.get(domain_spec.value_source)
|
||||
== domain_spec.target_value
|
||||
)
|
||||
return entity_state.state == domain_spec.target_value
|
||||
|
||||
|
||||
def make_cover_is_open_condition(
|
||||
*, device_classes: dict[str, str]
|
||||
) -> type[CoverConditionBase]:
|
||||
"""Create a condition for cover is open."""
|
||||
|
||||
class CoverIsOpenCondition(CoverConditionBase):
|
||||
"""Condition for cover is open."""
|
||||
|
||||
_domain_specs = {
|
||||
domain: CoverDomainSpec(
|
||||
device_class=dc,
|
||||
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
|
||||
target_value=False if domain == DOMAIN else STATE_ON,
|
||||
)
|
||||
for domain, dc in device_classes.items()
|
||||
}
|
||||
|
||||
return CoverIsOpenCondition
|
||||
|
||||
|
||||
def make_cover_is_closed_condition(
|
||||
*, device_classes: dict[str, str]
|
||||
) -> type[CoverConditionBase]:
|
||||
"""Create a condition for cover is closed."""
|
||||
|
||||
class CoverIsClosedCondition(CoverConditionBase):
|
||||
"""Condition for cover is closed."""
|
||||
|
||||
_domain_specs = {
|
||||
domain: CoverDomainSpec(
|
||||
device_class=dc,
|
||||
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
|
||||
target_value=True if domain == DOMAIN else STATE_OFF,
|
||||
)
|
||||
for domain, dc in device_classes.items()
|
||||
}
|
||||
|
||||
return CoverIsClosedCondition
|
||||
|
||||
|
||||
DEVICE_CLASSES_AWNING: dict[str, str] = {DOMAIN: CoverDeviceClass.AWNING}
|
||||
DEVICE_CLASSES_BLIND: dict[str, str] = {DOMAIN: CoverDeviceClass.BLIND}
|
||||
DEVICE_CLASSES_CURTAIN: dict[str, str] = {DOMAIN: CoverDeviceClass.CURTAIN}
|
||||
DEVICE_CLASSES_SHADE: dict[str, str] = {DOMAIN: CoverDeviceClass.SHADE}
|
||||
DEVICE_CLASSES_SHUTTER: dict[str, str] = {DOMAIN: CoverDeviceClass.SHUTTER}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"awning_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_AWNING
|
||||
),
|
||||
"awning_is_open": make_cover_is_open_condition(
|
||||
device_classes=DEVICE_CLASSES_AWNING
|
||||
),
|
||||
"blind_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_BLIND
|
||||
),
|
||||
"blind_is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_BLIND),
|
||||
"curtain_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_CURTAIN
|
||||
),
|
||||
"curtain_is_open": make_cover_is_open_condition(
|
||||
device_classes=DEVICE_CLASSES_CURTAIN
|
||||
),
|
||||
"shade_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_SHADE
|
||||
),
|
||||
"shade_is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_SHADE),
|
||||
"shutter_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_SHUTTER
|
||||
),
|
||||
"shutter_is_open": make_cover_is_open_condition(
|
||||
device_classes=DEVICE_CLASSES_SHUTTER
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the conditions for covers."""
|
||||
return CONDITIONS
|
||||
@@ -1,80 +0,0 @@
|
||||
.condition_common_fields: &condition_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
awning_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: awning
|
||||
|
||||
awning_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: awning
|
||||
|
||||
blind_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: blind
|
||||
|
||||
blind_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: blind
|
||||
|
||||
curtain_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: curtain
|
||||
|
||||
curtain_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: curtain
|
||||
|
||||
shade_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shade
|
||||
|
||||
shade_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shade
|
||||
|
||||
shutter_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shutter
|
||||
|
||||
shutter_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shutter
|
||||
@@ -1,36 +1,4 @@
|
||||
{
|
||||
"conditions": {
|
||||
"awning_is_closed": {
|
||||
"condition": "mdi:storefront-outline"
|
||||
},
|
||||
"awning_is_open": {
|
||||
"condition": "mdi:storefront-outline"
|
||||
},
|
||||
"blind_is_closed": {
|
||||
"condition": "mdi:blinds-horizontal-closed"
|
||||
},
|
||||
"blind_is_open": {
|
||||
"condition": "mdi:blinds-horizontal"
|
||||
},
|
||||
"curtain_is_closed": {
|
||||
"condition": "mdi:curtains-closed"
|
||||
},
|
||||
"curtain_is_open": {
|
||||
"condition": "mdi:curtains"
|
||||
},
|
||||
"shade_is_closed": {
|
||||
"condition": "mdi:roller-shade-closed"
|
||||
},
|
||||
"shade_is_open": {
|
||||
"condition": "mdi:roller-shade"
|
||||
},
|
||||
"shutter_is_closed": {
|
||||
"condition": "mdi:window-shutter"
|
||||
},
|
||||
"shutter_is_open": {
|
||||
"condition": "mdi:window-shutter-open"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:window-open",
|
||||
|
||||
@@ -15,6 +15,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
INTENT_OPEN_COVER,
|
||||
DOMAIN,
|
||||
SERVICE_OPEN_COVER,
|
||||
"Opening {}",
|
||||
description="Opens a cover",
|
||||
platforms={DOMAIN},
|
||||
device_classes={CoverDeviceClass},
|
||||
@@ -26,6 +27,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
INTENT_CLOSE_COVER,
|
||||
DOMAIN,
|
||||
SERVICE_CLOSE_COVER,
|
||||
"Closing {}",
|
||||
description="Closes a cover",
|
||||
platforms={DOMAIN},
|
||||
device_classes={CoverDeviceClass},
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""Data models for the cover integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoverDomainSpec(DomainSpec):
|
||||
"""DomainSpec with a target value for comparison."""
|
||||
|
||||
target_value: str | bool | None = None
|
||||
@@ -1,112 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted covers.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted covers to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"awning_is_closed": {
|
||||
"description": "Tests if one or more awnings are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Awning is closed"
|
||||
},
|
||||
"awning_is_open": {
|
||||
"description": "Tests if one or more awnings are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Awning is open"
|
||||
},
|
||||
"blind_is_closed": {
|
||||
"description": "Tests if one or more blinds are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Blind is closed"
|
||||
},
|
||||
"blind_is_open": {
|
||||
"description": "Tests if one or more blinds are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Blind is open"
|
||||
},
|
||||
"curtain_is_closed": {
|
||||
"description": "Tests if one or more curtains are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Curtain is closed"
|
||||
},
|
||||
"curtain_is_open": {
|
||||
"description": "Tests if one or more curtains are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Curtain is open"
|
||||
},
|
||||
"shade_is_closed": {
|
||||
"description": "Tests if one or more shades are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shade is closed"
|
||||
},
|
||||
"shade_is_open": {
|
||||
"description": "Tests if one or more shades are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shade is open"
|
||||
},
|
||||
"shutter_is_closed": {
|
||||
"description": "Tests if one or more shutters are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shutter is closed"
|
||||
},
|
||||
"shutter_is_open": {
|
||||
"description": "Tests if one or more shutters are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shutter is open"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"close": "Close {entity_name}",
|
||||
@@ -191,12 +87,6 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
@@ -2,72 +2,80 @@
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
get_device_class_or_undefined,
|
||||
)
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
from .models import CoverDomainSpec
|
||||
|
||||
|
||||
class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]):
|
||||
class CoverTriggerBase(EntityTriggerBase):
|
||||
"""Base trigger for cover state changes."""
|
||||
|
||||
def _get_value(self, state: State) -> str | bool | None:
|
||||
"""Extract the relevant value from state based on domain spec."""
|
||||
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
|
||||
if domain_spec.value_source is not None:
|
||||
return state.attributes.get(domain_spec.value_source)
|
||||
return state.state
|
||||
_binary_sensor_target_state: str
|
||||
_cover_is_closed_target_value: bool
|
||||
_device_classes: dict[str, str]
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities by cover device class."""
|
||||
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_classes[split_entity_id(entity_id)[0]]
|
||||
}
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the state matches the target cover state."""
|
||||
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
|
||||
return self._get_value(state) == domain_spec.target_value
|
||||
if split_entity_id(state.entity_id)[0] == DOMAIN:
|
||||
return (
|
||||
state.attributes.get(ATTR_IS_CLOSED)
|
||||
== self._cover_is_closed_target_value
|
||||
)
|
||||
return state.state == self._binary_sensor_target_state
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the transition is valid for a cover state change."""
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
if (from_value := self._get_value(from_state)) is None:
|
||||
return False
|
||||
return from_value != self._get_value(to_state)
|
||||
if split_entity_id(from_state.entity_id)[0] == DOMAIN:
|
||||
if (from_is_closed := from_state.attributes.get(ATTR_IS_CLOSED)) is None:
|
||||
return False
|
||||
return from_is_closed != to_state.attributes.get(ATTR_IS_CLOSED) # type: ignore[no-any-return]
|
||||
return from_state.state != to_state.state
|
||||
|
||||
|
||||
def make_cover_opened_trigger(
|
||||
*, device_classes: dict[str, str]
|
||||
*, device_classes: dict[str, str], domains: set[str] | None = None
|
||||
) -> type[CoverTriggerBase]:
|
||||
"""Create a trigger cover_opened."""
|
||||
|
||||
class CoverOpenedTrigger(CoverTriggerBase):
|
||||
"""Trigger for cover opened state changes."""
|
||||
|
||||
_domain_specs = {
|
||||
domain: CoverDomainSpec(
|
||||
device_class=dc,
|
||||
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
|
||||
target_value=False if domain == DOMAIN else STATE_ON,
|
||||
)
|
||||
for domain, dc in device_classes.items()
|
||||
}
|
||||
_binary_sensor_target_state = STATE_ON
|
||||
_cover_is_closed_target_value = False
|
||||
_domains = domains or {DOMAIN}
|
||||
_device_classes = device_classes
|
||||
|
||||
return CoverOpenedTrigger
|
||||
|
||||
|
||||
def make_cover_closed_trigger(
|
||||
*, device_classes: dict[str, str]
|
||||
*, device_classes: dict[str, str], domains: set[str] | None = None
|
||||
) -> type[CoverTriggerBase]:
|
||||
"""Create a trigger cover_closed."""
|
||||
|
||||
class CoverClosedTrigger(CoverTriggerBase):
|
||||
"""Trigger for cover closed state changes."""
|
||||
|
||||
_domain_specs = {
|
||||
domain: CoverDomainSpec(
|
||||
device_class=dc,
|
||||
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
|
||||
target_value=True if domain == DOMAIN else STATE_OFF,
|
||||
)
|
||||
for domain, dc in device_classes.items()
|
||||
}
|
||||
_binary_sensor_target_state = STATE_OFF
|
||||
_cover_is_closed_target_value = True
|
||||
_domains = domains or {DOMAIN}
|
||||
_device_classes = device_classes
|
||||
|
||||
return CoverClosedTrigger
|
||||
|
||||
|
||||
@@ -38,9 +38,9 @@ from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import format_unserializable_data
|
||||
|
||||
from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType
|
||||
from .util import async_redact_data, entity_entry_as_dict
|
||||
from .util import async_redact_data
|
||||
|
||||
__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"]
|
||||
__all__ = ["REDACTED", "async_redact_data"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any, cast, overload
|
||||
|
||||
import attr
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
|
||||
from .const import REDACTED
|
||||
|
||||
@@ -45,16 +42,3 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T:
|
||||
redacted[key] = [async_redact_data(item, to_redact) for item in value]
|
||||
|
||||
return cast(_T, redacted)
|
||||
|
||||
|
||||
def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool:
|
||||
return a.name not in ("_cache", "compat_aliases", "compat_name")
|
||||
|
||||
|
||||
@callback
|
||||
def entity_entry_as_dict(entry: RegistryEntry) -> dict[str, Any]:
|
||||
"""Convert an entity registry entry to a dict for diagnostics.
|
||||
|
||||
This excludes internal fields that should not be exposed in diagnostics.
|
||||
"""
|
||||
return attr.asdict(entry, filter=_entity_entry_filter)
|
||||
|
||||
@@ -20,8 +20,14 @@ DEVICE_CLASSES_DOOR: dict[str, str] = {
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_DOOR),
|
||||
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_DOOR),
|
||||
"opened": make_cover_opened_trigger(
|
||||
device_classes=DEVICE_CLASSES_DOOR,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
"closed": make_cover_closed_trigger(
|
||||
device_classes=DEVICE_CLASSES_DOOR,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from attr import asdict
|
||||
from pyenphase.envoy import Envoy
|
||||
from pyenphase.exceptions import EnvoyError
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
@@ -111,7 +111,8 @@ async def async_get_config_entry_diagnostics(
|
||||
if state := hass.states.get(entity.entity_id):
|
||||
state_dict = dict(state.as_dict())
|
||||
state_dict.pop("context", None)
|
||||
entity_dict = entity_entry_as_dict(entity)
|
||||
entity_dict = asdict(entity)
|
||||
entity_dict.pop("_cache", None)
|
||||
entities.append({"entity": entity_dict, "state": state_dict})
|
||||
device_dict = asdict(device)
|
||||
device_dict.pop("_cache", None)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.4.6"],
|
||||
"requirements": ["pyenphase==2.4.5"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -160,23 +160,6 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
_native_supported_color_modes: tuple[ESPHomeColorMode, ...]
|
||||
_supports_color_mode = False
|
||||
|
||||
def _color_temp_to_cold_warm(self, color_temp_mired: float) -> tuple[float, float]:
|
||||
"""Convert a color temperature in mireds to cold/warm white fractions.
|
||||
|
||||
Returns (cold_white, warm_white) normalized so the brighter channel
|
||||
is 1.0.
|
||||
"""
|
||||
static_info = self._static_info
|
||||
min_mireds = static_info.min_mireds
|
||||
max_mireds = static_info.max_mireds
|
||||
if max_mireds <= min_mireds:
|
||||
return 1.0, 1.0
|
||||
color_temp_clamped = min(max(color_temp_mired, min_mireds), max_mireds)
|
||||
ww_frac = (color_temp_clamped - min_mireds) / (max_mireds - min_mireds)
|
||||
cw_frac = 1 - ww_frac
|
||||
max_frac = max(cw_frac, ww_frac)
|
||||
return cw_frac / max_frac, ww_frac / max_frac
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def is_on(self) -> bool:
|
||||
@@ -258,19 +241,12 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
|
||||
if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None:
|
||||
# Do not use kelvin_to_mired here to prevent precision loss
|
||||
color_temp_mired = 1_000_000.0 / color_temp_k
|
||||
data["color_temperature"] = 1_000_000.0 / color_temp_k
|
||||
if color_temp_modes := _filter_color_modes(
|
||||
color_modes, LightColorCapability.COLOR_TEMPERATURE
|
||||
):
|
||||
data["color_temperature"] = color_temp_mired
|
||||
color_modes = color_temp_modes
|
||||
else:
|
||||
# Convert color temperature to explicit cold/warm white
|
||||
# values to avoid ESPHome applying brightness to both
|
||||
# master brightness and white channels (b² effect).
|
||||
data["cold_white"], data["warm_white"] = self._color_temp_to_cold_warm(
|
||||
color_temp_mired
|
||||
)
|
||||
color_modes = _filter_color_modes(
|
||||
color_modes, LightColorCapability.COLD_WARM_WHITE
|
||||
)
|
||||
@@ -369,13 +345,19 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE
|
||||
):
|
||||
# Try to reverse white + color temp to cwww
|
||||
static_info = self._static_info
|
||||
min_ct = static_info.min_mireds
|
||||
max_ct = static_info.max_mireds
|
||||
color_temp = min(max(state.color_temperature, min_ct), max_ct)
|
||||
white = state.white
|
||||
cw, ww = self._color_temp_to_cold_warm(state.color_temperature)
|
||||
|
||||
ww_frac = (color_temp - min_ct) / (max_ct - min_ct)
|
||||
cw_frac = 1 - ww_frac
|
||||
|
||||
return (
|
||||
*rgb,
|
||||
round(white * cw * 255),
|
||||
round(white * ww * 255),
|
||||
round(white * cw_frac / max(cw_frac, ww_frac) * 255),
|
||||
round(white * ww_frac / max(cw_frac, ww_frac) * 255),
|
||||
)
|
||||
return (
|
||||
*rgb,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==44.5.2",
|
||||
"aioesphomeapi==44.3.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.1"
|
||||
],
|
||||
|
||||
@@ -123,13 +123,19 @@ class EsphomeAssistSatelliteWakeWordSelect(
|
||||
|
||||
def __init__(self, entry_data: RuntimeEntryData, index: int = 0) -> None:
|
||||
"""Initialize a wake word selector."""
|
||||
if index >= 1:
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"wake_word_{index + 1}",
|
||||
translation_key="wake_word_n",
|
||||
translation_placeholders={"index": str(index + 1)},
|
||||
)
|
||||
if index < 1:
|
||||
# Keep compatibility
|
||||
key_suffix = ""
|
||||
placeholder = ""
|
||||
else:
|
||||
key_suffix = f"_{index + 1}"
|
||||
placeholder = f" {index + 1}"
|
||||
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"wake_word{key_suffix}",
|
||||
translation_placeholders={"index": placeholder},
|
||||
)
|
||||
|
||||
EsphomeAssistEntity.__init__(self, entry_data)
|
||||
|
||||
|
||||
@@ -107,12 +107,6 @@
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]",
|
||||
"state": {
|
||||
@@ -122,18 +116,11 @@
|
||||
}
|
||||
},
|
||||
"wake_word": {
|
||||
"name": "Wake word",
|
||||
"name": "Wake word{index}",
|
||||
"state": {
|
||||
"no_wake_word": "No wake word",
|
||||
"okay_nabu": "Okay Nabu"
|
||||
}
|
||||
},
|
||||
"wake_word_n": {
|
||||
"name": "Wake word {index}",
|
||||
"state": {
|
||||
"no_wake_word": "[%key:component::esphome::entity::select::wake_word::state::no_wake_word%]",
|
||||
"okay_nabu": "[%key:component::esphome::entity::select::wake_word::state::okay_nabu%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,13 +5,7 @@ from __future__ import annotations
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import (
|
||||
EntityInfo,
|
||||
WaterHeaterFeature,
|
||||
WaterHeaterInfo,
|
||||
WaterHeaterMode,
|
||||
WaterHeaterState,
|
||||
)
|
||||
from aioesphomeapi import EntityInfo, WaterHeaterInfo, WaterHeaterMode, WaterHeaterState
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
WaterHeaterEntity,
|
||||
@@ -60,7 +54,6 @@ class EsphomeWaterHeater(
|
||||
static_info = self._static_info
|
||||
self._attr_min_temp = static_info.min_temperature
|
||||
self._attr_max_temp = static_info.max_temperature
|
||||
self._attr_target_temperature_step = static_info.target_temperature_step
|
||||
features = WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||
if static_info.supported_modes:
|
||||
features |= WaterHeaterEntityFeature.OPERATION_MODE
|
||||
@@ -70,8 +63,6 @@ class EsphomeWaterHeater(
|
||||
]
|
||||
else:
|
||||
self._attr_operation_list = None
|
||||
if static_info.supported_features & WaterHeaterFeature.SUPPORTS_ON_OFF:
|
||||
features |= WaterHeaterEntityFeature.ON_OFF
|
||||
self._attr_supported_features = features
|
||||
|
||||
@property
|
||||
@@ -110,24 +101,6 @@ class EsphomeWaterHeater(
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the water heater on."""
|
||||
self._client.water_heater_command(
|
||||
key=self._key,
|
||||
on=True,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the water heater off."""
|
||||
self._client.water_heater_command(
|
||||
key=self._key,
|
||||
on=False,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
platform_async_setup_entry,
|
||||
|
||||
@@ -103,8 +103,6 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Initialize flow from zeroconf."""
|
||||
zeroconf_properties = discovery_info.properties
|
||||
host = zeroconf_properties.get("api_domain")
|
||||
if not host:
|
||||
return self.async_abort(reason="missing_api_domain")
|
||||
port = zeroconf_properties.get("https_port") or discovery_info.port
|
||||
host = zeroconf_properties["api_domain"]
|
||||
port = zeroconf_properties["https_port"]
|
||||
return await self.async_step_user({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"missing_api_domain": "The discovered Freebox service did not provide the required API domain. Try again later or configure the Freebox manually."
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
@@ -57,42 +56,3 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, _user_input: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
client = FreshrClient(session=async_get_clientsession(self.hass))
|
||||
try:
|
||||
await client.login(
|
||||
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except LoginError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
|
||||
description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/freshr",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyfreshr==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"wrong_account": "Cannot change the account username."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -10,15 +12,6 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::freshr::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "Re-enter the password for your Fresh-r account `{username}`."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["fritzconnection"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -29,7 +29,9 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: we are close to the goal of 95%
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -4,9 +4,9 @@ set_guest_wifi_password:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: fritz
|
||||
entity:
|
||||
integration: fritz
|
||||
domain: update
|
||||
device_class: connectivity
|
||||
password:
|
||||
required: false
|
||||
selector:
|
||||
@@ -23,9 +23,9 @@ dial:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: fritz
|
||||
entity:
|
||||
integration: fritz
|
||||
domain: update
|
||||
device_class: connectivity
|
||||
number:
|
||||
required: true
|
||||
selector:
|
||||
|
||||
@@ -133,20 +133,26 @@ async def _async_wifi_entities_list(
|
||||
]
|
||||
)
|
||||
_LOGGER.debug("WiFi networks count: %s", wifi_count)
|
||||
networks: dict[int, dict[str, Any]] = {}
|
||||
networks: dict = {}
|
||||
for i in range(1, wifi_count + 1):
|
||||
network_info = await avm_wrapper.async_get_wlan_configuration(i)
|
||||
# Devices with 4 WLAN services, use the 2nd for internal communications
|
||||
if not (wifi_count == 4 and i == 2):
|
||||
networks[i] = network_info
|
||||
networks[i] = {
|
||||
"ssid": network_info["NewSSID"],
|
||||
"bssid": network_info["NewBSSID"],
|
||||
"standard": network_info["NewStandard"],
|
||||
"enabled": network_info["NewEnable"],
|
||||
"status": network_info["NewStatus"],
|
||||
}
|
||||
for i, network in networks.copy().items():
|
||||
networks[i]["switch_name"] = network["NewSSID"]
|
||||
networks[i]["switch_name"] = network["ssid"]
|
||||
if (
|
||||
len(
|
||||
[
|
||||
j
|
||||
for j, n in networks.items()
|
||||
if slugify(n["NewSSID"]) == slugify(network["NewSSID"])
|
||||
if slugify(n["ssid"]) == slugify(network["ssid"])
|
||||
]
|
||||
)
|
||||
> 1
|
||||
@@ -428,11 +434,13 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
|
||||
for key, attr in attributes_dict.items():
|
||||
self._attributes[attr] = self.port_mapping[key]
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> bool:
|
||||
self.port_mapping["NewEnabled"] = "1" if turn_on else "0"
|
||||
await self._avm_wrapper.async_add_port_mapping(
|
||||
|
||||
resp = await self._avm_wrapper.async_add_port_mapping(
|
||||
self.connection_type, self.port_mapping
|
||||
)
|
||||
return bool(resp is not None)
|
||||
|
||||
|
||||
class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch):
|
||||
@@ -517,11 +525,12 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
|
||||
"""Turn off switch."""
|
||||
await self._async_handle_turn_on_off(turn_on=False)
|
||||
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> None:
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> bool:
|
||||
"""Handle switch state change request."""
|
||||
await self._avm_wrapper.async_set_allow_wan_access(self.ip_address, turn_on)
|
||||
self._avm_wrapper.devices[self._mac].wan_access = turn_on
|
||||
self.async_write_ha_state()
|
||||
return True
|
||||
|
||||
|
||||
class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
@@ -532,11 +541,10 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
avm_wrapper: AvmWrapper,
|
||||
device_friendly_name: str,
|
||||
network_num: int,
|
||||
network_data: dict[str, Any],
|
||||
network_data: dict,
|
||||
) -> None:
|
||||
"""Init Fritz Wifi switch."""
|
||||
self._avm_wrapper = avm_wrapper
|
||||
self._wifi_info = network_data
|
||||
|
||||
self._attributes = {}
|
||||
self._attr_entity_category = EntityCategory.CONFIG
|
||||
@@ -552,7 +560,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
type=SWITCH_TYPE_WIFINETWORK,
|
||||
callback_update=self._async_fetch_update,
|
||||
callback_switch=self._async_switch_on_off_executor,
|
||||
init_state=network_data["NewEnable"],
|
||||
init_state=network_data["enabled"],
|
||||
)
|
||||
super().__init__(self._avm_wrapper, device_friendly_name, switch_info)
|
||||
|
||||
@@ -579,9 +587,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
self._attributes["mac_address_control"] = wifi_info[
|
||||
"NewMACAddressControlEnabled"
|
||||
]
|
||||
self._wifi_info = wifi_info
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
"""Handle wifi switch."""
|
||||
self._wifi_info["NewEnable"] = turn_on
|
||||
await self._avm_wrapper.async_set_wlan_configuration(self._network_num, turn_on)
|
||||
|
||||
@@ -179,9 +179,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
return PRESET_HOLIDAY
|
||||
if self.data.summer_active:
|
||||
return PRESET_SUMMER
|
||||
if self.data.target_temperature == ON_API_TEMPERATURE or getattr(
|
||||
self.data, "boost_active", False
|
||||
):
|
||||
if self.data.target_temperature == ON_API_TEMPERATURE:
|
||||
return PRESET_BOOST
|
||||
if self.data.target_temperature == self.data.comfort_temperature:
|
||||
return PRESET_COMFORT
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260312.0"]
|
||||
"requirements": ["home-assistant-frontend==20260304.0"]
|
||||
}
|
||||
|
||||
@@ -20,8 +20,14 @@ DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = {
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
|
||||
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
|
||||
"opened": make_cover_opened_trigger(
|
||||
device_classes=DEVICE_CLASSES_GARAGE_DOOR,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
"closed": make_cover_closed_trigger(
|
||||
device_classes=DEVICE_CLASSES_GARAGE_DOOR,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
@@ -35,7 +35,6 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VALVE,
|
||||
@@ -91,10 +90,8 @@ async def async_setup_entry(
|
||||
|
||||
name = entry.title
|
||||
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
|
||||
name = await client.read_char(AquaContour.custom_device_name, name)
|
||||
|
||||
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
|
||||
await _update_timestamp(client, AquaContour.unix_timestamp)
|
||||
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
await client.disconnect()
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from gardena_bluetooth.const import AquaContour, Sensor, Valve
|
||||
from gardena_bluetooth.const import Sensor, Valve
|
||||
from gardena_bluetooth.parse import CharacteristicBool
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -47,13 +47,6 @@ DESCRIPTIONS = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothBinarySensorEntityDescription(
|
||||
key=AquaContour.frost_warning.unique_id,
|
||||
translation_key="frost_warning",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=AquaContour.frost_warning,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ def _is_supported(discovery_info: BluetoothServiceInfo):
|
||||
ProductType.WATER_COMPUTER,
|
||||
ProductType.AUTOMATS,
|
||||
ProductType.PRESSURE_TANKS,
|
||||
ProductType.AQUA_CONTOURS,
|
||||
):
|
||||
_LOGGER.debug("Unsupported device: %s", manufacturer_data)
|
||||
return False
|
||||
@@ -71,7 +70,6 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_read_data(self):
|
||||
"""Try to connect to device and extract information."""
|
||||
assert self.address
|
||||
client = Client(get_connection(self.hass, self.address))
|
||||
try:
|
||||
model = await client.read_char(DeviceInformation.model_number)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from gardena_bluetooth.const import DeviceConfiguration, Sensor, Spray, Valve
|
||||
from gardena_bluetooth.const import DeviceConfiguration, Sensor, Valve
|
||||
from gardena_bluetooth.parse import (
|
||||
Characteristic,
|
||||
CharacteristicInt,
|
||||
@@ -18,7 +18,7 @@ from homeassistant.components.number import (
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.const import DEGREE, PERCENTAGE, EntityCategory, UnitOfTime
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -34,7 +34,6 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
|
||||
default_factory=lambda: CharacteristicInt("")
|
||||
)
|
||||
connected_state: Characteristic | None = None
|
||||
scale: float = 1.0
|
||||
|
||||
@property
|
||||
def context(self) -> set[str]:
|
||||
@@ -105,27 +104,6 @@ DESCRIPTIONS = (
|
||||
char=Sensor.threshold,
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key="spray_sector",
|
||||
translation_key="spray_sector",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=0.0,
|
||||
native_max_value=359.0,
|
||||
native_step=1.0,
|
||||
char=Spray.sector,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key="spray_distance",
|
||||
translation_key="spray_distance",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
mode=NumberMode.SLIDER,
|
||||
native_min_value=0.0,
|
||||
native_max_value=100.0,
|
||||
native_step=0.1,
|
||||
char=Spray.distance,
|
||||
scale=10.0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -156,7 +134,7 @@ class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity):
|
||||
if data is None:
|
||||
self._attr_native_value = None
|
||||
else:
|
||||
self._attr_native_value = float(data) / self.entity_description.scale
|
||||
self._attr_native_value = float(data)
|
||||
|
||||
if char := self.entity_description.connected_state:
|
||||
self._attr_available = bool(self.coordinator.get_cached(char))
|
||||
@@ -167,9 +145,7 @@ class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity):
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set new value."""
|
||||
await self.coordinator.write(
|
||||
self.entity_description.char, int(value * self.entity_description.scale)
|
||||
)
|
||||
await self.coordinator.write(self.entity_description.char, int(value))
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Support for select entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
|
||||
from gardena_bluetooth.const import (
|
||||
AquaContour,
|
||||
AquaContourPosition,
|
||||
AquaContourWatering,
|
||||
)
|
||||
from gardena_bluetooth.parse import CharacteristicInt
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import GardenaBluetoothConfigEntry
|
||||
from .entity import GardenaBluetoothDescriptorEntity
|
||||
|
||||
|
||||
def _enum_to_int(enum: type[IntEnum]) -> dict[str, int]:
|
||||
return {member.name.lower(): member.value for member in enum}
|
||||
|
||||
|
||||
def _reverse_dict(value: dict[str, int]) -> dict[int, str]:
|
||||
return {value: key for key, value in value.items()}
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GardenaBluetoothSelectEntityDescription(SelectEntityDescription):
|
||||
"""Description of entity."""
|
||||
|
||||
key: str = field(init=False)
|
||||
char: CharacteristicInt
|
||||
option_to_number: dict[str, int]
|
||||
number_to_option: dict[int, str] = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize calculated fields."""
|
||||
object.__setattr__(self, "key", self.char.unique_id)
|
||||
object.__setattr__(self, "options", list(self.option_to_number.keys()))
|
||||
object.__setattr__(
|
||||
self, "number_to_option", _reverse_dict(self.option_to_number)
|
||||
)
|
||||
|
||||
@property
|
||||
def context(self) -> set[str]:
|
||||
"""Context needed for update coordinator."""
|
||||
return {self.char.uuid}
|
||||
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothSelectEntityDescription(
|
||||
translation_key="watering_active",
|
||||
char=AquaContourWatering.watering_active,
|
||||
option_to_number=_enum_to_int(AquaContourWatering.watering_active.enum),
|
||||
),
|
||||
GardenaBluetoothSelectEntityDescription(
|
||||
translation_key="operation_mode",
|
||||
char=AquaContour.operation_mode,
|
||||
option_to_number=_enum_to_int(AquaContour.operation_mode.enum),
|
||||
),
|
||||
GardenaBluetoothSelectEntityDescription(
|
||||
translation_key="active_position",
|
||||
char=AquaContourPosition.active_position,
|
||||
option_to_number={
|
||||
"position_1": 1,
|
||||
"position_2": 2,
|
||||
"position_3": 3,
|
||||
"position_4": 4,
|
||||
"position_5": 5,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GardenaBluetoothConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up select based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
entities = [
|
||||
GardenaBluetoothSelectEntity(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class GardenaBluetoothSelectEntity(GardenaBluetoothDescriptorEntity, SelectEntity):
|
||||
"""Representation of a select entity."""
|
||||
|
||||
entity_description: GardenaBluetoothSelectEntityDescription
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the selected entity option to represent the entity state."""
|
||||
char = self.entity_description.char
|
||||
value = self.coordinator.get_cached(char)
|
||||
if value is None:
|
||||
return None
|
||||
return self.entity_description.number_to_option.get(value)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
char = self.entity_description.char
|
||||
value = self.entity_description.option_to_number[option]
|
||||
await self.coordinator.write(char, value)
|
||||
self.async_write_ha_state()
|
||||
@@ -2,19 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from gardena_bluetooth.const import (
|
||||
AquaContourBattery,
|
||||
Battery,
|
||||
EventHistory,
|
||||
FlowStatistics,
|
||||
Sensor,
|
||||
Spray,
|
||||
Valve,
|
||||
)
|
||||
from gardena_bluetooth.const import Battery, Sensor, Valve
|
||||
from gardena_bluetooth.parse import Characteristic
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -22,15 +13,8 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
DEGREE,
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfVolume,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -38,28 +22,13 @@ from homeassistant.util import dt as dt_util
|
||||
from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator
|
||||
from .entity import GardenaBluetoothDescriptorEntity, GardenaBluetoothEntity
|
||||
|
||||
type SensorRawType = StateType | datetime
|
||||
|
||||
|
||||
def _get_timestamp(value: datetime | None):
|
||||
if value is None:
|
||||
return None
|
||||
return value.replace(tzinfo=dt_util.get_default_time_zone())
|
||||
|
||||
|
||||
def _get_distance_ratio(value: int | None):
|
||||
if value is None:
|
||||
return None
|
||||
return value / 1000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GardenaBluetoothSensorEntityDescription[T](SensorEntityDescription):
|
||||
class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
|
||||
"""Description of entity."""
|
||||
|
||||
char: Characteristic[T] = field(default_factory=lambda: Characteristic(""))
|
||||
char: Characteristic = field(default_factory=lambda: Characteristic(""))
|
||||
connected_state: Characteristic | None = None
|
||||
get: Callable[[T | None], SensorRawType] = lambda x: x # type: ignore[assignment, return-value]
|
||||
|
||||
@property
|
||||
def context(self) -> set[str]:
|
||||
@@ -87,14 +56,6 @@ DESCRIPTIONS = (
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
char=Battery.battery_level,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=AquaContourBattery.battery_level.unique_id,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
char=AquaContourBattery.battery_level,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.battery_level.unique_id,
|
||||
translation_key="sensor_battery_level",
|
||||
@@ -127,78 +88,6 @@ DESCRIPTIONS = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Sensor.measurement_timestamp,
|
||||
connected_state=Sensor.connected_state,
|
||||
get=_get_timestamp,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.overall.unique_id,
|
||||
translation_key="flow_statistics_overall",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
char=FlowStatistics.overall,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.current.unique_id,
|
||||
translation_key="flow_statistics_current",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
|
||||
char=FlowStatistics.current,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.resettable.unique_id,
|
||||
translation_key="flow_statistics_resettable",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
char=FlowStatistics.resettable,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.last_reset.unique_id,
|
||||
translation_key="flow_statistics_reset_timestamp",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=FlowStatistics.last_reset,
|
||||
get=_get_timestamp,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Spray.current_distance.unique_id,
|
||||
translation_key="spray_current_distance",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
char=Spray.current_distance,
|
||||
get=_get_distance_ratio,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Spray.current_sector.unique_id,
|
||||
translation_key="spray_current_sector",
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=DEGREE,
|
||||
char=Spray.current_sector,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key="aqua_contour_error",
|
||||
translation_key="aqua_contour_error",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
char=EventHistory.error,
|
||||
get=lambda x: (
|
||||
x.error_code.name.lower()
|
||||
if x and isinstance(x.error_code, EventHistory.error.enum)
|
||||
else None
|
||||
),
|
||||
options=[member.name.lower() for member in EventHistory.error.enum],
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key="aqua_contour_error_timestamp",
|
||||
translation_key="error_timestamp",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
char=EventHistory.error,
|
||||
get=lambda x: _get_timestamp(x.time_stamp) if x else None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -227,7 +116,8 @@ class GardenaBluetoothSensor(GardenaBluetoothDescriptorEntity, SensorEntity):
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
value = self.coordinator.get_cached(self.entity_description.char)
|
||||
value = self.entity_description.get(value)
|
||||
if isinstance(value, datetime):
|
||||
value = value.replace(tzinfo=dt_util.get_default_time_zone())
|
||||
self._attr_native_value = value
|
||||
|
||||
if char := self.entity_description.connected_state:
|
||||
|
||||
@@ -22,9 +22,6 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"frost_warning": {
|
||||
"name": "Frost"
|
||||
},
|
||||
"sensor_connected_state": {
|
||||
"name": "Sensor connection"
|
||||
},
|
||||
@@ -55,79 +52,12 @@
|
||||
},
|
||||
"sensor_threshold": {
|
||||
"name": "Sensor threshold"
|
||||
},
|
||||
"spray_distance": {
|
||||
"name": "Distance"
|
||||
},
|
||||
"spray_sector": {
|
||||
"name": "Sector"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"active_position": {
|
||||
"name": "Active position",
|
||||
"state": {
|
||||
"position_1": "Position 1",
|
||||
"position_2": "Position 2",
|
||||
"position_3": "Position 3",
|
||||
"position_4": "Position 4",
|
||||
"position_5": "Position 5"
|
||||
}
|
||||
},
|
||||
"operation_mode": {
|
||||
"name": "Operation mode",
|
||||
"state": {
|
||||
"active": "Active",
|
||||
"deep_sleep": "Deep sleep",
|
||||
"manual_mode": "Manual",
|
||||
"pre_winter": "Winter preparation"
|
||||
}
|
||||
},
|
||||
"watering_active": {
|
||||
"name": "Watering",
|
||||
"state": {
|
||||
"contour_1": "Contour 1",
|
||||
"contour_2": "Contour 2",
|
||||
"contour_3": "Contour 3",
|
||||
"contour_4": "Contour 4",
|
||||
"contour_5": "Contour 5",
|
||||
"preview": "Preview",
|
||||
"rest": "Idle",
|
||||
"setup_mode": "Setup"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"activation_reason": {
|
||||
"name": "Activation reason"
|
||||
},
|
||||
"aqua_contour_error": {
|
||||
"name": "Error",
|
||||
"state": {
|
||||
"charger_error": "Charger error",
|
||||
"flash_error": "Flash error",
|
||||
"no_error": "No error detected",
|
||||
"no_water": "Not enough water",
|
||||
"rotation_sensor_error": "Rotation sensor error",
|
||||
"sprinkler_motor_error": "Sprinkler motor error",
|
||||
"valve_motor_error": "Valve motor error"
|
||||
}
|
||||
},
|
||||
"error_timestamp": {
|
||||
"name": "Error timestamp"
|
||||
},
|
||||
"flow_statistics_current": {
|
||||
"name": "Current flow"
|
||||
},
|
||||
"flow_statistics_overall": {
|
||||
"name": "Overall flow"
|
||||
},
|
||||
"flow_statistics_reset_timestamp": {
|
||||
"name": "Flow reset timestamp"
|
||||
},
|
||||
"flow_statistics_resettable": {
|
||||
"name": "Flow since reset"
|
||||
},
|
||||
"remaining_open_timestamp": {
|
||||
"name": "Valve closing"
|
||||
},
|
||||
@@ -139,12 +69,6 @@
|
||||
},
|
||||
"sensor_type": {
|
||||
"name": "Sensor type"
|
||||
},
|
||||
"spray_current_distance": {
|
||||
"name": "Current distance"
|
||||
},
|
||||
"spray_current_sector": {
|
||||
"name": "Current sector"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.helpers.helper_integration import (
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
)
|
||||
|
||||
from .const import CONF_DUR_COOLDOWN, CONF_HEATER, CONF_MIN_DUR, CONF_SENSOR, PLATFORMS
|
||||
from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,13 +91,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
)
|
||||
if config_entry.minor_version < 3:
|
||||
# Set `cycle_cooldown` to `min_cycle_duration` to mimic the old behavior
|
||||
if CONF_MIN_DUR in options:
|
||||
options[CONF_DUR_COOLDOWN] = options[CONF_MIN_DUR]
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, options=options, minor_version=3
|
||||
config_entry, options=options, minor_version=2
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
@@ -39,9 +38,7 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
DOMAIN as HOMEASSISTANT_DOMAIN,
|
||||
Context,
|
||||
CoreState,
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
@@ -49,30 +46,27 @@ from homeassistant.core import (
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.exceptions import ConditionError
|
||||
from homeassistant.helpers import condition, config_validation as cv
|
||||
from homeassistant.helpers.device import async_entity_id_to_device
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.event import (
|
||||
async_call_later,
|
||||
async_track_state_change_event,
|
||||
async_track_time_interval,
|
||||
)
|
||||
from homeassistant.helpers.reload import async_setup_reload_service
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_AC_MODE,
|
||||
CONF_COLD_TOLERANCE,
|
||||
CONF_DUR_COOLDOWN,
|
||||
CONF_HEATER,
|
||||
CONF_HOT_TOLERANCE,
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_DUR,
|
||||
CONF_MAX_TEMP,
|
||||
CONF_MIN_DUR,
|
||||
CONF_MIN_TEMP,
|
||||
@@ -104,8 +98,6 @@ PLATFORM_SCHEMA_COMMON = vol.Schema(
|
||||
vol.Optional(CONF_AC_MODE): cv.boolean,
|
||||
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_MIN_DUR): cv.positive_time_period,
|
||||
vol.Optional(CONF_MAX_DUR): cv.positive_time_period,
|
||||
vol.Optional(CONF_DUR_COOLDOWN): cv.positive_time_period,
|
||||
vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
|
||||
@@ -175,8 +167,6 @@ async def _async_setup_config(
|
||||
target_temp: float | None = config.get(CONF_TARGET_TEMP)
|
||||
ac_mode: bool | None = config.get(CONF_AC_MODE)
|
||||
min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR)
|
||||
max_cycle_duration: timedelta | None = config.get(CONF_MAX_DUR)
|
||||
cycle_cooldown: timedelta | None = config.get(CONF_DUR_COOLDOWN)
|
||||
cold_tolerance: float = config[CONF_COLD_TOLERANCE]
|
||||
hot_tolerance: float = config[CONF_HOT_TOLERANCE]
|
||||
keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE)
|
||||
@@ -200,8 +190,6 @@ async def _async_setup_config(
|
||||
target_temp=target_temp,
|
||||
ac_mode=ac_mode,
|
||||
min_cycle_duration=min_cycle_duration,
|
||||
max_cycle_duration=max_cycle_duration,
|
||||
cycle_cooldown=cycle_cooldown,
|
||||
cold_tolerance=cold_tolerance,
|
||||
hot_tolerance=hot_tolerance,
|
||||
keep_alive=keep_alive,
|
||||
@@ -233,8 +221,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
target_temp: float | None,
|
||||
ac_mode: bool | None,
|
||||
min_cycle_duration: timedelta | None,
|
||||
max_cycle_duration: timedelta | None,
|
||||
cycle_cooldown: timedelta | None,
|
||||
cold_tolerance: float,
|
||||
hot_tolerance: float,
|
||||
keep_alive: timedelta | None,
|
||||
@@ -254,16 +240,8 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
heater_entity_id,
|
||||
)
|
||||
self.ac_mode = ac_mode
|
||||
self.min_cycle_duration = min_cycle_duration or timedelta()
|
||||
self.max_cycle_duration = max_cycle_duration
|
||||
self.cycle_cooldown = cycle_cooldown or timedelta()
|
||||
self.min_cycle_duration = min_cycle_duration
|
||||
self._cold_tolerance = cold_tolerance
|
||||
# Subtract the cooldown so it doesn't impact startup
|
||||
self._last_toggled_time = dt_util.utcnow() - self.cycle_cooldown
|
||||
self._cycle_callback: CALLBACK_TYPE | None = None
|
||||
self._check_callback: CALLBACK_TYPE | None = None
|
||||
# Context ID used to detect our own toggles
|
||||
self._last_context_id: str | None = None
|
||||
self._hot_tolerance = hot_tolerance
|
||||
self._keep_alive = keep_alive
|
||||
self._hvac_mode = initial_hvac_mode
|
||||
@@ -311,7 +289,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
self.hass, [self.heater_entity_id], self._async_switch_changed
|
||||
)
|
||||
)
|
||||
self.async_on_remove(self._cancel_timers)
|
||||
|
||||
if self._keep_alive:
|
||||
self.async_on_remove(
|
||||
@@ -505,18 +482,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
self.hass.async_create_task(
|
||||
self._check_switch_initial_state(), eager_start=True
|
||||
)
|
||||
|
||||
# Update timestamp on toggle
|
||||
self._last_toggled_time = new_state.last_changed
|
||||
|
||||
# If the user toggles the switch, assume they want control and clear the timers.
|
||||
# Note: If a manual interaction occurs within the 2s context window of a switch
|
||||
# toggle initiated by us, we may not detect manual control. Users are advised to
|
||||
# use the climate entity for reliable control, not the switch entity.
|
||||
if new_state.context.id != self._last_context_id:
|
||||
_LOGGER.debug("External switch change detected, clearing timers")
|
||||
self._last_context_id = None
|
||||
self._cancel_timers()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
@@ -552,69 +517,57 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
if not self._active or self._hvac_mode == HVACMode.OFF:
|
||||
return
|
||||
|
||||
if force and time is not None and self.max_cycle_duration:
|
||||
# We were invoked due to `max_cycle_duration`, so turn off
|
||||
_LOGGER.debug(
|
||||
"Turning off heater %s due to max cycle time of %s",
|
||||
self.heater_entity_id,
|
||||
self.max_cycle_duration,
|
||||
)
|
||||
self._cancel_cycle_timer()
|
||||
await self._async_heater_turn_off()
|
||||
return
|
||||
# If the `force` argument is True, we
|
||||
# ignore `min_cycle_duration`.
|
||||
# If the `time` argument is not none, we were invoked for
|
||||
# keep-alive purposes, and `min_cycle_duration` is irrelevant.
|
||||
if not force and time is None and self.min_cycle_duration:
|
||||
if self._is_device_active:
|
||||
current_state = STATE_ON
|
||||
else:
|
||||
current_state = HVACMode.OFF
|
||||
try:
|
||||
long_enough = condition.state(
|
||||
self.hass,
|
||||
self.heater_entity_id,
|
||||
current_state,
|
||||
self.min_cycle_duration,
|
||||
)
|
||||
except ConditionError:
|
||||
long_enough = False
|
||||
|
||||
if not long_enough:
|
||||
return
|
||||
|
||||
assert self._cur_temp is not None and self._target_temp is not None
|
||||
too_cold = self._target_temp > self._cur_temp + self._cold_tolerance
|
||||
too_hot = self._target_temp < self._cur_temp - self._hot_tolerance
|
||||
now = dt_util.utcnow()
|
||||
|
||||
min_temp = self._target_temp - self._cold_tolerance
|
||||
max_temp = self._target_temp + self._hot_tolerance
|
||||
|
||||
if self._is_device_active:
|
||||
if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot):
|
||||
# Make sure it's past the `min_cycle_duration` before turning off
|
||||
if (
|
||||
self._last_toggled_time + self.min_cycle_duration <= now
|
||||
or force
|
||||
):
|
||||
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
|
||||
await self._async_heater_turn_off()
|
||||
elif self._check_callback is None:
|
||||
_LOGGER.debug(
|
||||
"Minimum cycle time not reached, check again at %s",
|
||||
self._last_toggled_time + self.min_cycle_duration,
|
||||
)
|
||||
self._check_callback = async_call_later(
|
||||
self.hass,
|
||||
now - self._last_toggled_time + self.min_cycle_duration,
|
||||
self._async_timer_control_heating,
|
||||
)
|
||||
if (self.ac_mode and self._cur_temp <= min_temp) or (
|
||||
not self.ac_mode and self._cur_temp >= max_temp
|
||||
):
|
||||
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
|
||||
await self._async_heater_turn_off()
|
||||
elif time is not None:
|
||||
# This is a keep-alive call, so ensure it's on
|
||||
# The time argument is passed only in keep-alive case
|
||||
_LOGGER.debug(
|
||||
"Keep-alive - Turning on heater %s",
|
||||
"Keep-alive - Turning on heater heater %s",
|
||||
self.heater_entity_id,
|
||||
)
|
||||
await self._async_heater_turn_on(keepalive=True)
|
||||
elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold):
|
||||
# Make sure it's past the `cycle_cooldown` before turning on
|
||||
if self._last_toggled_time + self.cycle_cooldown <= now or force:
|
||||
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
|
||||
await self._async_heater_turn_on()
|
||||
elif self._check_callback is None:
|
||||
_LOGGER.debug(
|
||||
"Cooldown time not reached, check again at %s",
|
||||
self._last_toggled_time + self.cycle_cooldown,
|
||||
)
|
||||
self._check_callback = async_call_later(
|
||||
self.hass,
|
||||
now - self._last_toggled_time + self.cycle_cooldown,
|
||||
self._async_timer_control_heating,
|
||||
)
|
||||
elif (self.ac_mode and self._cur_temp > max_temp) or (
|
||||
not self.ac_mode and self._cur_temp < min_temp
|
||||
):
|
||||
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
|
||||
await self._async_heater_turn_on()
|
||||
elif time is not None:
|
||||
# This is a keep-alive call, so ensure it's off
|
||||
# The time argument is passed only in keep-alive case
|
||||
_LOGGER.debug(
|
||||
"Keep-alive - Turning off heater %s", self.heater_entity_id
|
||||
)
|
||||
await self._async_heater_turn_off(keepalive=True)
|
||||
await self._async_heater_turn_off()
|
||||
|
||||
@property
|
||||
def _is_device_active(self) -> bool | None:
|
||||
@@ -624,48 +577,19 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
return self.hass.states.is_state(self.heater_entity_id, STATE_ON)
|
||||
|
||||
async def _async_heater_turn_on(self, keepalive: bool = False) -> None:
|
||||
async def _async_heater_turn_on(self) -> None:
|
||||
"""Turn heater toggleable device on."""
|
||||
data = {ATTR_ENTITY_ID: self.heater_entity_id}
|
||||
# Create a new context for this service call so we can identify
|
||||
# the resulting state change event as originating from us
|
||||
new_context = Context(parent_id=self._context.id if self._context else None)
|
||||
self.async_set_context(new_context)
|
||||
self._last_context_id = new_context.id
|
||||
await self.hass.services.async_call(
|
||||
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=new_context
|
||||
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=self._context
|
||||
)
|
||||
if not keepalive:
|
||||
# Update timestamp on turn on
|
||||
self._last_toggled_time = dt_util.utcnow()
|
||||
self._cancel_check_timer()
|
||||
if self.max_cycle_duration:
|
||||
_LOGGER.debug(
|
||||
"Scheduling maximum run-time shut-off for %s",
|
||||
self._last_toggled_time + self.max_cycle_duration,
|
||||
)
|
||||
self._cancel_cycle_timer()
|
||||
self._cycle_callback = async_call_later(
|
||||
self.hass,
|
||||
self.max_cycle_duration,
|
||||
partial(self._async_control_heating, force=True),
|
||||
)
|
||||
|
||||
async def _async_heater_turn_off(self, keepalive: bool = False) -> None:
|
||||
async def _async_heater_turn_off(self) -> None:
|
||||
"""Turn heater toggleable device off."""
|
||||
data = {ATTR_ENTITY_ID: self.heater_entity_id}
|
||||
# Create a new context for this service call so we can identify
|
||||
# the resulting state change event as originating from us
|
||||
new_context = Context(parent_id=self._context.id if self._context else None)
|
||||
self.async_set_context(new_context)
|
||||
self._last_context_id = new_context.id
|
||||
await self.hass.services.async_call(
|
||||
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=new_context
|
||||
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=self._context
|
||||
)
|
||||
if not keepalive:
|
||||
# Update timestamp on turn off
|
||||
self._last_toggled_time = dt_util.utcnow()
|
||||
self._cancel_timers()
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
@@ -689,30 +613,3 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
await self._async_control_heating(force=True)
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_timer_control_heating(self, _: datetime | None = None) -> None:
|
||||
"""Reset check timer and control heating."""
|
||||
self._check_callback = None
|
||||
await self._async_control_heating()
|
||||
|
||||
@callback
|
||||
def _cancel_check_timer(self) -> None:
|
||||
"""Reset check timer."""
|
||||
if self._check_callback:
|
||||
_LOGGER.debug("Cancelling scheduled state check")
|
||||
self._check_callback()
|
||||
self._check_callback = None
|
||||
|
||||
@callback
|
||||
def _cancel_cycle_timer(self) -> None:
|
||||
"""Reset cycle timer."""
|
||||
if self._cycle_callback:
|
||||
_LOGGER.debug("Cancelling scheduled shut-off")
|
||||
self._cycle_callback()
|
||||
self._cycle_callback = None
|
||||
|
||||
@callback
|
||||
def _cancel_timers(self) -> None:
|
||||
"""Reset timers."""
|
||||
self._cancel_check_timer()
|
||||
self._cancel_cycle_timer()
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -13,20 +12,16 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDevic
|
||||
from homeassistant.const import CONF_NAME, DEGREE
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
SchemaConfigFlowHandler,
|
||||
SchemaFlowError,
|
||||
SchemaFlowFormStep,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_AC_MODE,
|
||||
CONF_COLD_TOLERANCE,
|
||||
CONF_DUR_COOLDOWN,
|
||||
CONF_HEATER,
|
||||
CONF_HOT_TOLERANCE,
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_DUR,
|
||||
CONF_MAX_TEMP,
|
||||
CONF_MIN_DUR,
|
||||
CONF_MIN_TEMP,
|
||||
@@ -68,12 +63,6 @@ OPTIONS_SCHEMA = {
|
||||
vol.Optional(CONF_KEEP_ALIVE): selector.DurationSelector(
|
||||
selector.DurationSelectorConfig(allow_negative=False)
|
||||
),
|
||||
vol.Optional(CONF_MAX_DUR): selector.DurationSelector(
|
||||
selector.DurationSelectorConfig(allow_negative=False)
|
||||
),
|
||||
vol.Optional(CONF_DUR_COOLDOWN): selector.DurationSelector(
|
||||
selector.DurationSelectorConfig(allow_negative=False)
|
||||
),
|
||||
vol.Optional(CONF_MIN_TEMP): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1
|
||||
@@ -101,31 +90,13 @@ CONFIG_SCHEMA = {
|
||||
}
|
||||
|
||||
|
||||
async def _validate_config(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Validate config."""
|
||||
if all(x in user_input for x in (CONF_MIN_DUR, CONF_MAX_DUR)):
|
||||
min_cycle = timedelta(**user_input[CONF_MIN_DUR])
|
||||
max_cycle = timedelta(**user_input[CONF_MAX_DUR])
|
||||
|
||||
if min_cycle >= max_cycle:
|
||||
raise SchemaFlowError("min_max_runtime")
|
||||
|
||||
return user_input
|
||||
|
||||
|
||||
CONFIG_FLOW = {
|
||||
"user": SchemaFlowFormStep(vol.Schema(CONFIG_SCHEMA), next_step="presets"),
|
||||
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
|
||||
}
|
||||
|
||||
OPTIONS_FLOW = {
|
||||
"init": SchemaFlowFormStep(
|
||||
vol.Schema(OPTIONS_SCHEMA),
|
||||
validate_user_input=_validate_config,
|
||||
next_step="presets",
|
||||
),
|
||||
"init": SchemaFlowFormStep(vol.Schema(OPTIONS_SCHEMA), next_step="presets"),
|
||||
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
|
||||
}
|
||||
|
||||
@@ -133,7 +104,7 @@ OPTIONS_FLOW = {
|
||||
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
"""Handle a config or options flow."""
|
||||
|
||||
MINOR_VERSION = 3
|
||||
MINOR_VERSION = 2
|
||||
|
||||
config_flow = CONFIG_FLOW
|
||||
options_flow = OPTIONS_FLOW
|
||||
|
||||
@@ -20,8 +20,6 @@ CONF_HEATER = "heater"
|
||||
CONF_HOT_TOLERANCE = "hot_tolerance"
|
||||
CONF_MAX_TEMP = "max_temp"
|
||||
CONF_MIN_DUR = "min_cycle_duration"
|
||||
CONF_MAX_DUR = "max_cycle_duration"
|
||||
CONF_DUR_COOLDOWN = "cycle_cooldown"
|
||||
CONF_MIN_TEMP = "min_temp"
|
||||
CONF_PRESETS = {
|
||||
p: f"{p}_temp"
|
||||
|
||||
@@ -16,13 +16,11 @@
|
||||
"data": {
|
||||
"ac_mode": "Cooling mode",
|
||||
"cold_tolerance": "Cold tolerance",
|
||||
"cycle_cooldown": "Cooldown period after running",
|
||||
"heater": "Actuator switch",
|
||||
"hot_tolerance": "Hot tolerance",
|
||||
"keep_alive": "Keep-alive interval",
|
||||
"max_cycle_duration": "Maximum run time",
|
||||
"max_temp": "Maximum target temperature",
|
||||
"min_cycle_duration": "Minimum run time",
|
||||
"min_cycle_duration": "Minimum cycle duration",
|
||||
"min_temp": "Minimum target temperature",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"target_sensor": "Temperature sensor"
|
||||
@@ -30,12 +28,10 @@
|
||||
"data_description": {
|
||||
"ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.",
|
||||
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.",
|
||||
"cycle_cooldown": "After switching off, the minimum amount of time that must elapse before it can be switched back on.",
|
||||
"heater": "Switch entity used to cool or heat depending on A/C mode.",
|
||||
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5.",
|
||||
"keep_alive": "Trigger the heater periodically to keep devices from losing state.",
|
||||
"max_cycle_duration": "Once switched on, the maximum amount of time that can elapse before it will be switched off.",
|
||||
"min_cycle_duration": "Once switched on, the minimum amount of time that must elapse before it may be switched off.",
|
||||
"keep_alive": "Trigger the heater periodically to keep devices from losing state. When set, min cycle duration is ignored.",
|
||||
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
|
||||
"target_sensor": "Temperature sensor that reflects the current temperature."
|
||||
},
|
||||
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
|
||||
@@ -44,19 +40,14 @@
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"error": {
|
||||
"min_max_runtime": "Minimum run time must be less than the maximum run time."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"ac_mode": "[%key:component::generic_thermostat::config::step::user::data::ac_mode%]",
|
||||
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]",
|
||||
"cycle_cooldown": "[%key:component::generic_thermostat::config::step::user::data::cycle_cooldown%]",
|
||||
"heater": "[%key:component::generic_thermostat::config::step::user::data::heater%]",
|
||||
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]",
|
||||
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data::keep_alive%]",
|
||||
"max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::max_cycle_duration%]",
|
||||
"max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]",
|
||||
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]",
|
||||
"min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]",
|
||||
@@ -65,11 +56,9 @@
|
||||
"data_description": {
|
||||
"ac_mode": "[%key:component::generic_thermostat::config::step::user::data_description::ac_mode%]",
|
||||
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::cold_tolerance%]",
|
||||
"cycle_cooldown": "[%key:component::generic_thermostat::config::step::user::data_description::cycle_cooldown%]",
|
||||
"heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]",
|
||||
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::hot_tolerance%]",
|
||||
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data_description::keep_alive%]",
|
||||
"max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::max_cycle_duration%]",
|
||||
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::min_cycle_duration%]",
|
||||
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data_description::target_sensor%]"
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.2"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
intent,
|
||||
start,
|
||||
)
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
@@ -598,6 +597,7 @@ class GoogleEntity:
|
||||
state = self.state
|
||||
traits = self.traits()
|
||||
entity_config = self.config.entity_config.get(state.entity_id, {})
|
||||
name = (entity_config.get(CONF_NAME) or state.name).strip()
|
||||
|
||||
# Find entity/device/area registry entries
|
||||
entity_entry, device_entry, area_entry = _get_registry_entries(
|
||||
@@ -607,6 +607,7 @@ class GoogleEntity:
|
||||
# Build the device info
|
||||
device = {
|
||||
"id": state.entity_id,
|
||||
"name": {"name": name},
|
||||
"attributes": {},
|
||||
"traits": [trait.name for trait in traits],
|
||||
"willReportState": self.config.should_report_state,
|
||||
@@ -614,18 +615,13 @@ class GoogleEntity:
|
||||
state.domain, state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
),
|
||||
}
|
||||
# Add name and aliases.
|
||||
# The entity's alias list is ordered: the first slot naturally serves
|
||||
# as the primary name (set to the auto-generated full entity name by
|
||||
# default), while the rest serve as alternative names (nicknames).
|
||||
aliases = intent.async_get_entity_aliases(
|
||||
self.hass, entity_entry, state=state, allow_empty=False
|
||||
)
|
||||
name, *aliases = aliases
|
||||
name = entity_config.get(CONF_NAME) or name
|
||||
device["name"] = {"name": name}
|
||||
if (config_aliases := entity_config.get(CONF_ALIASES, [])) or aliases:
|
||||
device["name"]["nicknames"] = [name, *config_aliases, *aliases]
|
||||
# Add aliases
|
||||
if (config_aliases := entity_config.get(CONF_ALIASES, [])) or (
|
||||
entity_entry and entity_entry.aliases
|
||||
):
|
||||
device["name"]["nicknames"] = [name, *config_aliases]
|
||||
if entity_entry:
|
||||
device["name"]["nicknames"].extend(entity_entry.aliases)
|
||||
|
||||
# Add local SDK info if enabled
|
||||
if self.config.is_local_sdk_active and self.should_expose_local():
|
||||
|
||||
@@ -7,12 +7,7 @@ import aiohttp
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
@@ -44,11 +39,11 @@ async def async_setup_entry(
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except OAuth2TokenRequestReauthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"OAuth session is not valid, reauth required"
|
||||
) from err
|
||||
except OAuth2TokenRequestError as err:
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"OAuth session is not valid, reauth required"
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["network"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/govee_light_local",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["govee-local-api==2.4.0"]
|
||||
"requirements": ["govee-local-api==2.3.0"]
|
||||
}
|
||||
|
||||
@@ -239,9 +239,6 @@ def _login_classic_api(
|
||||
return login_response
|
||||
|
||||
|
||||
V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"}
|
||||
|
||||
|
||||
def get_device_list_v1(
|
||||
api, config: Mapping[str, str]
|
||||
) -> tuple[list[dict[str, str]], str]:
|
||||
@@ -263,17 +260,18 @@ def get_device_list_v1(
|
||||
f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})"
|
||||
) from e
|
||||
devices = devices_dict.get("devices", [])
|
||||
# Only MIN device (type = 7) support implemented in current V1 API
|
||||
supported_devices = [
|
||||
{
|
||||
"deviceSn": device.get("device_sn", ""),
|
||||
"deviceType": V1_DEVICE_TYPES[device.get("type")],
|
||||
"deviceType": "min",
|
||||
}
|
||||
for device in devices
|
||||
if device.get("type") in V1_DEVICE_TYPES
|
||||
if device.get("type") == 7
|
||||
]
|
||||
|
||||
for device in devices:
|
||||
if device.get("type") not in V1_DEVICE_TYPES:
|
||||
if device.get("type") != 7:
|
||||
_LOGGER.warning(
|
||||
"Device %s with type %s not supported in Open API V1, skipping",
|
||||
device.get("device_sn", ""),
|
||||
@@ -350,7 +348,7 @@ async def async_setup_entry(
|
||||
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
|
||||
)
|
||||
for device in devices
|
||||
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min", "sph"]
|
||||
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min"]
|
||||
}
|
||||
|
||||
# Perform the first refresh for the total coordinator
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user