mirror of
https://github.com/home-assistant/core.git
synced 2026-03-16 16:02:06 +01:00
Compare commits
221 Commits
copilot/su
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0167182e2e | ||
|
|
11411a880d | ||
|
|
ce47abe1d3 | ||
|
|
b58513c19a | ||
|
|
4e1dab6d8b | ||
|
|
5ae8e1c319 | ||
|
|
17bf6ca591 | ||
|
|
256d30c38d | ||
|
|
5d182394c2 | ||
|
|
011e6863d8 | ||
|
|
b902b590b1 | ||
|
|
960666e15b | ||
|
|
1fb59c9f11 | ||
|
|
332bf95e16 | ||
|
|
e35fc8267e | ||
|
|
f8b4ffc0d7 | ||
|
|
003ee5a699 | ||
|
|
c91d805174 | ||
|
|
c478d19ae3 | ||
|
|
09169b0f06 | ||
|
|
aa1dbee315 | ||
|
|
daf89e5673 | ||
|
|
85dc81c147 | ||
|
|
5acf24cb53 | ||
|
|
79829a311c | ||
|
|
ce2c62ae28 | ||
|
|
1cda3f47d6 | ||
|
|
e254716615 | ||
|
|
1d410f4cbd | ||
|
|
6616793e2b | ||
|
|
6766961327 | ||
|
|
dd6fc11d28 | ||
|
|
cb5b8b212c | ||
|
|
66b96d096e | ||
|
|
e86160de36 | ||
|
|
7617007edd | ||
|
|
3e065b31b3 | ||
|
|
5f909a6f3a | ||
|
|
6117a20ec6 | ||
|
|
93bc05bb3f | ||
|
|
e7397ccaa7 | ||
|
|
91a43873a2 | ||
|
|
469e06fb8c | ||
|
|
bac370e775 | ||
|
|
1a9da26286 | ||
|
|
f795707c53 | ||
|
|
9ad1356e4b | ||
|
|
0f70d5fd39 | ||
|
|
f4c6724953 | ||
|
|
82432d9ee7 | ||
|
|
8db07f3ceb | ||
|
|
2fe9d1ef86 | ||
|
|
cbb1f3726c | ||
|
|
beb122bb1a | ||
|
|
8d6099b055 | ||
|
|
7ebe11c0e6 | ||
|
|
12b14b46c0 | ||
|
|
cc45201f2d | ||
|
|
a433a163a3 | ||
|
|
7fd86145d1 | ||
|
|
f244af590e | ||
|
|
9a7dd98d89 | ||
|
|
6c4beba465 | ||
|
|
3a46beec76 | ||
|
|
d7c2dfc4d4 | ||
|
|
4efbafb003 | ||
|
|
1b10db28f1 | ||
|
|
1e988fbb04 | ||
|
|
9ab577aad4 | ||
|
|
ed53469eb6 | ||
|
|
56aa96a00c | ||
|
|
99c6cdbe44 | ||
|
|
1fd30b73e7 | ||
|
|
14aace0c00 | ||
|
|
6eed18623b | ||
|
|
66ca7d5782 | ||
|
|
a7436cbdc3 | ||
|
|
5e57b0272d | ||
|
|
e16b6ab026 | ||
|
|
e21fb14b9a | ||
|
|
8e099a874b | ||
|
|
a5302a6219 | ||
|
|
f761ac5b49 | ||
|
|
6988e73ddc | ||
|
|
a88374557b | ||
|
|
f2456b2c3a | ||
|
|
c1a525b7aa | ||
|
|
9d2febd24e | ||
|
|
54f96bcc33 | ||
|
|
5582d83f7b | ||
|
|
2832456bcd | ||
|
|
070c5821e4 | ||
|
|
07caa8ed2d | ||
|
|
b02f447e4d | ||
|
|
4fbb22e861 | ||
|
|
45199a341f | ||
|
|
de5f42d7a0 | ||
|
|
4459dce73a | ||
|
|
a465905467 | ||
|
|
a47faa3ced | ||
|
|
7276403ab9 | ||
|
|
018717af4f | ||
|
|
274c2b8092 | ||
|
|
bfe15a55c9 | ||
|
|
54ad67b810 | ||
|
|
4d2732df6f | ||
|
|
2be3291d8e | ||
|
|
4326cb96ea | ||
|
|
278894d4b4 | ||
|
|
eb17367229 | ||
|
|
d96191723f | ||
|
|
b6c7b2952e | ||
|
|
356de12bce | ||
|
|
57c49d0c48 | ||
|
|
af22b5fdbb | ||
|
|
9c710961f0 | ||
|
|
2a2da83173 | ||
|
|
00a52245e3 | ||
|
|
adb30e1ec1 | ||
|
|
34a7fcf8d3 | ||
|
|
95a57a2984 | ||
|
|
7f39cc0aeb | ||
|
|
6962288e85 | ||
|
|
fab4355cc8 | ||
|
|
e39d84e8fc | ||
|
|
35f597223a | ||
|
|
9d61c8336d | ||
|
|
6fd3603b7b | ||
|
|
49ac5c42ee | ||
|
|
df0db5853c | ||
|
|
7afc5b777c | ||
|
|
595aeea8cc | ||
|
|
02abba02d1 | ||
|
|
4ca1ad96f1 | ||
|
|
9f3beba97a | ||
|
|
9f86006328 | ||
|
|
4ac651d0b4 | ||
|
|
9e54abbcb5 | ||
|
|
d5915c8811 | ||
|
|
0c2887df9e | ||
|
|
3767bac850 | ||
|
|
9d962d3815 | ||
|
|
786fd40ae8 | ||
|
|
5ec65dbd58 | ||
|
|
35878bb203 | ||
|
|
e14d88ff55 | ||
|
|
d04efbfe48 | ||
|
|
3f35cd5cd2 | ||
|
|
86ffd58665 | ||
|
|
6206392b28 | ||
|
|
b7c36c707f | ||
|
|
973c32b99d | ||
|
|
951775bea6 | ||
|
|
0f2dbdf4f4 | ||
|
|
443ff7efe1 | ||
|
|
0ee6b954df | ||
|
|
5681acf0e1 | ||
|
|
a94458b8bc | ||
|
|
f3c38ba2d3 | ||
|
|
c1acd1d860 | ||
|
|
f4748aa63d | ||
|
|
31f4f618cc | ||
|
|
30aec4d2ab | ||
|
|
335abd7002 | ||
|
|
3b3f0e9240 | ||
|
|
49586d1519 | ||
|
|
c63ded3522 | ||
|
|
2eb65ab314 | ||
|
|
402a37b435 | ||
|
|
aa66e8ef0c | ||
|
|
f1a1e284b7 | ||
|
|
08594f4e0c | ||
|
|
8d810588f8 | ||
|
|
70faad15d5 | ||
|
|
d447843687 | ||
|
|
83b64e29fa | ||
|
|
4558a10e05 | ||
|
|
5ad9e81082 | ||
|
|
ba00a14772 | ||
|
|
49f4d07eeb | ||
|
|
5d271a0d30 | ||
|
|
474b683d3c | ||
|
|
d37106a360 | ||
|
|
e115c90719 | ||
|
|
6ad3adf0c3 | ||
|
|
2a8d59be4c | ||
|
|
6e6e35bc3b | ||
|
|
795b4c8414 | ||
|
|
16389dc18e | ||
|
|
e7a1c8d001 | ||
|
|
4efb10dae1 | ||
|
|
f163576e78 | ||
|
|
cad8f97e97 | ||
|
|
4ae6099d84 | ||
|
|
60dc88fa15 | ||
|
|
2d2c6d676d | ||
|
|
f3879335ab | ||
|
|
11bc00038e | ||
|
|
6845e8b880 | ||
|
|
5741016931 | ||
|
|
6cbc4e7f62 | ||
|
|
4064df0114 | ||
|
|
789f850691 | ||
|
|
efca71852b | ||
|
|
1967e9f309 | ||
|
|
6ac0c163aa | ||
|
|
bbe20fd698 | ||
|
|
f576743340 | ||
|
|
3b4a1fba5f | ||
|
|
1677a9bfa6 | ||
|
|
0d9c458705 | ||
|
|
57026a862d | ||
|
|
fd05be4c52 | ||
|
|
b1f038849e | ||
|
|
b46c9ccc65 | ||
|
|
80601426cf | ||
|
|
9519bd2428 | ||
|
|
be0b7f06a8 | ||
|
|
d30c6de168 | ||
|
|
0fa666518e | ||
|
|
cf454a1fa3 |
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@@ -18,6 +18,11 @@ 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.
|
||||
|
||||
24
.github/workflows/builder.yml
vendored
24
.github/workflows/builder.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
- name: Download Translations
|
||||
run: python3 -m script.translations download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
- name: Archive translations
|
||||
shell: bash
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build variables
|
||||
id: vars
|
||||
@@ -242,7 +242,7 @@ jobs:
|
||||
|
||||
- name: Build base image
|
||||
id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -328,7 +328,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -406,13 +406,13 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -442,7 +442,7 @@ jobs:
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -456,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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -585,14 +585,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -605,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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@@ -609,7 +609,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
|
||||
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
@@ -1400,7 +1400,7 @@ jobs:
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
pytest-partial:
|
||||
name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
|
||||
@@ -1570,7 +1570,7 @@ jobs:
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
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@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
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 }}
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }}
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# 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 }}
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
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 }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
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 }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
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.22.0
|
||||
rev: v1.23.1
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
|
||||
@@ -342,6 +342,7 @@ homeassistant.components.lookin.*
|
||||
homeassistant.components.lovelace.*
|
||||
homeassistant.components.luftdaten.*
|
||||
homeassistant.components.lunatone.*
|
||||
homeassistant.components.lutron.*
|
||||
homeassistant.components.madvr.*
|
||||
homeassistant.components.manual.*
|
||||
homeassistant.components.mastodon.*
|
||||
@@ -569,6 +570,7 @@ 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,6 +15,11 @@ 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,6 +186,8 @@ 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
|
||||
@@ -577,6 +579,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/garages_amsterdam/ @klaasnicolaas
|
||||
/homeassistant/components/gardena_bluetooth/ @elupus
|
||||
/tests/components/gardena_bluetooth/ @elupus
|
||||
/homeassistant/components/gate/ @home-assistant/core
|
||||
/tests/components/gate/ @home-assistant/core
|
||||
/homeassistant/components/gdacs/ @exxamalte
|
||||
/tests/components/gdacs/ @exxamalte
|
||||
/homeassistant/components/generic/ @davet2001
|
||||
@@ -1069,6 +1073,8 @@ 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
|
||||
@@ -1182,6 +1188,8 @@ 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
|
||||
@@ -1762,6 +1770,8 @@ 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
|
||||
@@ -1778,6 +1788,8 @@ 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
|
||||
@@ -1903,6 +1915,8 @@ 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
|
||||
|
||||
@@ -243,7 +243,11 @@ DEFAULT_INTEGRATIONS = {
|
||||
# Integrations providing triggers and conditions for base platforms:
|
||||
"door",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidity",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"window",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
# These integrations are set up if recovery mode is activated.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"domain": "ubiquiti",
|
||||
"name": "Ubiquiti",
|
||||
"integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"]
|
||||
"integrations": [
|
||||
"airos",
|
||||
"unifi",
|
||||
"unifi_access",
|
||||
"unifi_direct",
|
||||
"unifiled",
|
||||
"unifiprotect"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
EntityStateConditionBase,
|
||||
@@ -43,7 +44,7 @@ def make_entity_state_required_features_condition(
|
||||
class CustomCondition(EntityStateRequiredFeaturesCondition):
|
||||
"""Condition for entity state changes."""
|
||||
|
||||
_domain = domain
|
||||
_domain_specs = {domain: DomainSpec()}
|
||||
_states = {to_state}
|
||||
_required_features = required_features
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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,
|
||||
@@ -44,7 +45,7 @@ def make_entity_state_trigger_required_features(
|
||||
class CustomTrigger(EntityStateTriggerRequiredFeatures):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domains = {domain}
|
||||
_domain_specs = {domain: DomainSpec()}
|
||||
_to_states = {to_state}
|
||||
_required_features = required_features
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""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
|
||||
@@ -25,20 +24,15 @@ 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=model,
|
||||
model=self.device.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
|
||||
if model != SPEAKER_GROUP_DEVICE_TYPE
|
||||
else None
|
||||
),
|
||||
serial_number=serial_num if model != SPEAKER_GROUP_DEVICE_TYPE else None,
|
||||
sw_version=self.device.software_version,
|
||||
serial_number=serial_num,
|
||||
)
|
||||
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.0"]
|
||||
"requirements": ["aioamazondevices==13.0.1"]
|
||||
}
|
||||
|
||||
@@ -101,7 +101,10 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
assert method is not None
|
||||
|
||||
await method(self.device, state)
|
||||
await self.coordinator.async_request_refresh()
|
||||
self.coordinator.data[self.device.serial_number].sensors[
|
||||
self.entity_description.key
|
||||
].value = state
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from pyanglianwater.meter import SmartMeter
|
||||
@@ -32,13 +33,14 @@ class AnglianWaterSensor(StrEnum):
|
||||
YESTERDAY_WATER_COST = "yesterday_water_cost"
|
||||
YESTERDAY_SEWERAGE_COST = "yesterday_sewerage_cost"
|
||||
LATEST_READING = "latest_reading"
|
||||
LAST_UPDATED = "last_updated"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AnglianWaterSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes AnglianWater sensor entity."""
|
||||
|
||||
value_fn: Callable[[SmartMeter], float]
|
||||
value_fn: Callable[[SmartMeter], float | datetime | None]
|
||||
|
||||
|
||||
ENTITY_DESCRIPTIONS: tuple[AnglianWaterSensorEntityDescription, ...] = (
|
||||
@@ -76,6 +78,13 @@ ENTITY_DESCRIPTIONS: tuple[AnglianWaterSensorEntityDescription, ...] = (
|
||||
translation_key=AnglianWaterSensor.YESTERDAY_SEWERAGE_COST,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
AnglianWaterSensorEntityDescription(
|
||||
key=AnglianWaterSensor.LAST_UPDATED,
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda entity: entity.last_updated,
|
||||
translation_key=AnglianWaterSensor.LAST_UPDATED,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -112,6 +121,6 @@ class AnglianWaterSensorEntity(AnglianWaterEntity, SensorEntity):
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
def native_value(self) -> float | datetime | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.smart_meter)
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_updated": {
|
||||
"name": "Last meter reading processed"
|
||||
},
|
||||
"latest_reading": {
|
||||
"name": "Latest reading"
|
||||
},
|
||||
|
||||
@@ -8,46 +8,55 @@ from typing import Any
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import (
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
SIGNAL_CLIENT_DATA,
|
||||
SIGNAL_CLIENT_STARTED,
|
||||
SIGNAL_CLIENT_STOPPED,
|
||||
)
|
||||
|
||||
type ArcamFmjConfigEntry = ConfigEntry[Client]
|
||||
from .const import DEFAULT_SCAN_INTERVAL
|
||||
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRuntimeData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
|
||||
"""Set up config entry."""
|
||||
entry.runtime_data = Client(entry.data[CONF_HOST], entry.data[CONF_PORT])
|
||||
client = Client(entry.data[CONF_HOST], entry.data[CONF_PORT])
|
||||
|
||||
coordinators: dict[int, ArcamFmjCoordinator] = {}
|
||||
for zone in (1, 2):
|
||||
coordinator = ArcamFmjCoordinator(hass, entry, client, zone)
|
||||
coordinators[zone] = coordinator
|
||||
|
||||
entry.runtime_data = ArcamFmjRuntimeData(client, coordinators)
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass, _run_client(hass, entry.runtime_data, DEFAULT_SCAN_INTERVAL), "arcam_fmj"
|
||||
hass,
|
||||
_run_client(hass, entry.runtime_data, DEFAULT_SCAN_INTERVAL),
|
||||
"arcam_fmj",
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
|
||||
"""Cleanup before removing config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> None:
|
||||
async def _run_client(
|
||||
hass: HomeAssistant,
|
||||
runtime_data: ArcamFmjRuntimeData,
|
||||
interval: float,
|
||||
) -> None:
|
||||
client = runtime_data.client
|
||||
coordinators = runtime_data.coordinators
|
||||
|
||||
def _listen(_: Any) -> None:
|
||||
async_dispatcher_send(hass, SIGNAL_CLIENT_DATA, client.host)
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_data_updated()
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -55,16 +64,21 @@ async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> N
|
||||
await client.start()
|
||||
|
||||
_LOGGER.debug("Client connected %s", client.host)
|
||||
async_dispatcher_send(hass, SIGNAL_CLIENT_STARTED, client.host)
|
||||
|
||||
try:
|
||||
for coordinator in coordinators.values():
|
||||
await coordinator.state.start()
|
||||
|
||||
with client.listen(_listen):
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_connected()
|
||||
await client.process()
|
||||
finally:
|
||||
await client.stop()
|
||||
|
||||
_LOGGER.debug("Client disconnected %s", client.host)
|
||||
async_dispatcher_send(hass, SIGNAL_CLIENT_STOPPED, client.host)
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_disconnected()
|
||||
|
||||
except ConnectionFailed:
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
68
homeassistant/components/arcam_fmj/binary_sensor.py
Normal file
68
homeassistant/components/arcam_fmj/binary_sensor.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Arcam binary sensors for incoming stream info."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ArcamFmjConfigEntry
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ArcamFmjBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes an Arcam FMJ binary sensor entity."""
|
||||
|
||||
value_fn: Callable[[State], bool | None]
|
||||
|
||||
|
||||
BINARY_SENSORS: tuple[ArcamFmjBinarySensorEntityDescription, ...] = (
|
||||
ArcamFmjBinarySensorEntityDescription(
|
||||
key="incoming_video_interlaced",
|
||||
translation_key="incoming_video_interlaced",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda state: (
|
||||
vp.interlaced
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ArcamFmjConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Arcam FMJ binary sensors from a config entry."""
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
entities: list[ArcamFmjBinarySensorEntity] = []
|
||||
for coordinator in coordinators.values():
|
||||
entities.extend(
|
||||
ArcamFmjBinarySensorEntity(coordinator, description)
|
||||
for description in BINARY_SENSORS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ArcamFmjBinarySensorEntity(ArcamFmjEntity, BinarySensorEntity):
|
||||
"""Representation of an Arcam FMJ binary sensor."""
|
||||
|
||||
entity_description: ArcamFmjBinarySensorEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the binary sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.state)
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
DOMAIN = "arcam_fmj"
|
||||
|
||||
SIGNAL_CLIENT_STARTED = "arcam.client_started"
|
||||
SIGNAL_CLIENT_STOPPED = "arcam.client_stopped"
|
||||
SIGNAL_CLIENT_DATA = "arcam.client_data"
|
||||
|
||||
EVENT_TURN_ON = "arcam_fmj.turn_on"
|
||||
|
||||
DEFAULT_PORT = 50000
|
||||
|
||||
97
homeassistant/components/arcam_fmj/coordinator.py
Normal file
97
homeassistant/components/arcam_fmj/coordinator.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Coordinator for Arcam FMJ integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArcamFmjRuntimeData:
|
||||
"""Runtime data for Arcam FMJ integration."""
|
||||
|
||||
client: Client
|
||||
coordinators: dict[int, ArcamFmjCoordinator]
|
||||
|
||||
|
||||
type ArcamFmjConfigEntry = ConfigEntry[ArcamFmjRuntimeData]
|
||||
|
||||
|
||||
class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Coordinator for a single Arcam FMJ zone."""
|
||||
|
||||
config_entry: ArcamFmjConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ArcamFmjConfigEntry,
|
||||
client: Client,
|
||||
zone: int,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"Arcam FMJ zone {zone}",
|
||||
)
|
||||
self.client = client
|
||||
self.state = State(client, zone)
|
||||
self.last_update_success = False
|
||||
|
||||
name = config_entry.title
|
||||
unique_id = config_entry.unique_id or config_entry.entry_id
|
||||
unique_id_device = unique_id
|
||||
if zone != 1:
|
||||
unique_id_device += f"-{zone}"
|
||||
name += f" Zone {zone}"
|
||||
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id_device)},
|
||||
manufacturer="Arcam",
|
||||
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)
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data for manual refresh."""
|
||||
try:
|
||||
await self.state.update()
|
||||
except ConnectionFailed as err:
|
||||
raise UpdateFailed(
|
||||
f"Connection failed during update for zone {self.state.zn}"
|
||||
) from err
|
||||
|
||||
@callback
|
||||
def async_notify_data_updated(self) -> None:
|
||||
"""Notify that new data has been received from the device."""
|
||||
self.async_set_updated_data(None)
|
||||
|
||||
@callback
|
||||
def async_notify_connected(self) -> None:
|
||||
"""Handle client connected."""
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
|
||||
@callback
|
||||
def async_notify_disconnected(self) -> None:
|
||||
"""Handle client disconnected."""
|
||||
self.last_update_success = False
|
||||
self.async_update_listeners()
|
||||
28
homeassistant/components/arcam_fmj/entity.py
Normal file
28
homeassistant/components/arcam_fmj/entity.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""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
|
||||
35
homeassistant/components/arcam_fmj/icons.json
Normal file
35
homeassistant/components/arcam_fmj/icons.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from arcam.fmj import ConnectionFailed, SourceCodes
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseError,
|
||||
@@ -20,20 +19,13 @@ from homeassistant.components.media_player import (
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ArcamFmjConfigEntry
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
EVENT_TURN_ON,
|
||||
SIGNAL_CLIENT_DATA,
|
||||
SIGNAL_CLIENT_STARTED,
|
||||
SIGNAL_CLIENT_STOPPED,
|
||||
)
|
||||
from .const import EVENT_TURN_ON
|
||||
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,19 +36,10 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the configuration entry."""
|
||||
|
||||
client = config_entry.runtime_data
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
ArcamFmj(
|
||||
config_entry.title,
|
||||
State(client, zone),
|
||||
config_entry.unique_id or config_entry.entry_id,
|
||||
)
|
||||
for zone in (1, 2)
|
||||
],
|
||||
True,
|
||||
[ArcamFmj(coordinators[zone]) for zone in (1, 2)],
|
||||
)
|
||||
|
||||
|
||||
@@ -77,21 +60,13 @@ def convert_exception[**_P, _R](
|
||||
return _convert_exception
|
||||
|
||||
|
||||
class ArcamFmj(MediaPlayerEntity):
|
||||
class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
|
||||
"""Representation of a media device."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_name: str,
|
||||
state: State,
|
||||
uuid: str,
|
||||
) -> None:
|
||||
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
|
||||
"""Initialize device."""
|
||||
self._state = state
|
||||
self._attr_name = f"Zone {state.zn}"
|
||||
super().__init__(coordinator)
|
||||
self._state = coordinator.state
|
||||
self._attr_supported_features = (
|
||||
MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
@@ -102,18 +77,8 @@ class ArcamFmj(MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
| MediaPlayerEntityFeature.TURN_ON
|
||||
)
|
||||
if state.zn == 1:
|
||||
if self._state.zn == 1:
|
||||
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
self._attr_unique_id = f"{uuid}-{state.zn}"
|
||||
self._attr_entity_registry_enabled_default = state.zn == 1
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, uuid),
|
||||
},
|
||||
manufacturer="Arcam",
|
||||
model="Arcam FMJ AVR",
|
||||
name=device_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
@@ -122,49 +87,6 @@ class ArcamFmj(MediaPlayerEntity):
|
||||
return MediaPlayerState.ON
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Once registered, add listener for events."""
|
||||
await self._state.start()
|
||||
try:
|
||||
await self._state.update()
|
||||
except ConnectionFailed as connection:
|
||||
_LOGGER.debug("Connection lost during addition: %s", connection)
|
||||
|
||||
@callback
|
||||
def _data(host: str) -> None:
|
||||
if host == self._state.client.host:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _started(host: str) -> None:
|
||||
if host == self._state.client.host:
|
||||
self.async_schedule_update_ha_state(force_refresh=True)
|
||||
|
||||
@callback
|
||||
def _stopped(host: str) -> None:
|
||||
if host == self._state.client.host:
|
||||
self.async_schedule_update_ha_state(force_refresh=True)
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, SIGNAL_CLIENT_DATA, _data)
|
||||
)
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, SIGNAL_CLIENT_STARTED, _started)
|
||||
)
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, SIGNAL_CLIENT_STOPPED, _stopped)
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Force update of state."""
|
||||
_LOGGER.debug("Update state %s", self.name)
|
||||
try:
|
||||
await self._state.update()
|
||||
except ConnectionFailed as connection:
|
||||
_LOGGER.debug("Connection lost during update: %s", connection)
|
||||
|
||||
@convert_exception
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Send mute command."""
|
||||
|
||||
162
homeassistant/components/arcam_fmj/sensor.py
Normal file
162
homeassistant/components/arcam_fmj/sensor.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""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,5 +23,121 @@
|
||||
"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,19 +78,13 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
|
||||
index: int = 0,
|
||||
) -> None:
|
||||
"""Initialize a pipeline selector."""
|
||||
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},
|
||||
)
|
||||
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)},
|
||||
)
|
||||
|
||||
self._domain = domain
|
||||
self._unique_id_prefix = unique_id_prefix
|
||||
|
||||
@@ -7,11 +7,17 @@
|
||||
},
|
||||
"select": {
|
||||
"pipeline": {
|
||||
"name": "Assistant{index}",
|
||||
"name": "Assistant",
|
||||
"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 ClientResponseError
|
||||
from aiohttp import ClientError
|
||||
from yalexs.exceptions import AugustApiAIOHTTPError
|
||||
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
from yalexs.manager.gateway import Config as YaleXSConfig
|
||||
@@ -13,7 +13,12 @@ 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
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
@@ -45,11 +50,18 @@ 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, ClientResponseError, CannotConnect) as err:
|
||||
except (
|
||||
AugustApiAIOHTTPError,
|
||||
OAuth2TokenRequestError,
|
||||
ClientError,
|
||||
CannotConnect,
|
||||
) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
@@ -137,7 +137,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"binary_sensor",
|
||||
"button",
|
||||
"climate",
|
||||
"cover",
|
||||
@@ -145,12 +144,16 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"door",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"input_boolean",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"person",
|
||||
"remote",
|
||||
"scene",
|
||||
@@ -160,6 +163,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"text",
|
||||
"update",
|
||||
"vacuum",
|
||||
"window",
|
||||
}
|
||||
|
||||
|
||||
|
||||
53
homeassistant/components/autoskope/__init__.py
Normal file
53
homeassistant/components/autoskope/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""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)
|
||||
89
homeassistant/components/autoskope/config_flow.py
Normal file
89
homeassistant/components/autoskope/config_flow.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""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
|
||||
)
|
||||
9
homeassistant/components/autoskope/const.py
Normal file
9
homeassistant/components/autoskope/const.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""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)
|
||||
60
homeassistant/components/autoskope/coordinator.py
Normal file
60
homeassistant/components/autoskope/coordinator.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""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
|
||||
145
homeassistant/components/autoskope/device_tracker.py
Normal file
145
homeassistant/components/autoskope/device_tracker.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""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"
|
||||
11
homeassistant/components/autoskope/manifest.json
Normal file
11
homeassistant/components/autoskope/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
88
homeassistant/components/autoskope/quality_scale.yaml
Normal file
88
homeassistant/components/autoskope/quality_scale.yaml
Normal file
@@ -0,0 +1,88 @@
|
||||
# + 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.
|
||||
52
homeassistant/components/autoskope/strings.json
Normal file
52
homeassistant/components/autoskope/strings.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"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})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ from homeassistant.helpers import (
|
||||
issue_registry as ir,
|
||||
start,
|
||||
)
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
from homeassistant.util import dt as dt_util, json as json_util
|
||||
from homeassistant.util.async_iterator import AsyncIteratorReader
|
||||
@@ -78,6 +79,8 @@ from .util import (
|
||||
validate_password_stream,
|
||||
)
|
||||
|
||||
UPLOAD_PROGRESS_DEBOUNCE_SECONDS = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class NewBackup:
|
||||
@@ -141,6 +144,7 @@ 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"
|
||||
@@ -590,23 +594,49 @@ class BackupManager:
|
||||
)
|
||||
agent = self.backup_agents[agent_id]
|
||||
|
||||
latest_uploaded_bytes = 0
|
||||
|
||||
@callback
|
||||
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
|
||||
"""Handle upload progress."""
|
||||
def _emit_upload_progress() -> None:
|
||||
"""Emit the latest upload progress event."""
|
||||
self.async_on_backup_event(
|
||||
UploadBackupEvent(
|
||||
manager_state=self.state,
|
||||
agent_id=agent_id,
|
||||
uploaded_bytes=bytes_uploaded,
|
||||
uploaded_bytes=latest_uploaded_bytes,
|
||||
total_bytes=_backup.size,
|
||||
)
|
||||
)
|
||||
|
||||
upload_progress_debouncer: Debouncer[None] = Debouncer(
|
||||
self.hass,
|
||||
LOGGER,
|
||||
cooldown=UPLOAD_PROGRESS_DEBOUNCE_SECONDS,
|
||||
immediate=True,
|
||||
function=_emit_upload_progress,
|
||||
)
|
||||
|
||||
@callback
|
||||
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
|
||||
"""Handle upload progress."""
|
||||
nonlocal latest_uploaded_bytes
|
||||
latest_uploaded_bytes = bytes_uploaded
|
||||
upload_progress_debouncer.async_schedule_call()
|
||||
|
||||
await agent.async_upload_backup(
|
||||
open_stream=open_stream_func,
|
||||
backup=_backup,
|
||||
on_progress=on_upload_progress,
|
||||
)
|
||||
upload_progress_debouncer.async_cancel()
|
||||
self.async_on_backup_event(
|
||||
UploadBackupEvent(
|
||||
manager_state=self.state,
|
||||
agent_id=agent_id,
|
||||
uploaded_bytes=_backup.size,
|
||||
total_bytes=_backup.size,
|
||||
)
|
||||
)
|
||||
if streamer:
|
||||
await streamer.wait()
|
||||
|
||||
@@ -1261,6 +1291,13 @@ 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:
|
||||
|
||||
@@ -174,13 +174,5 @@
|
||||
"on": "mdi:window-open"
|
||||
}
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"occupancy_cleared": {
|
||||
"trigger": "mdi:home-outline"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"trigger": "mdi:home"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description_occupancy": "The behavior of the targeted occupancy sensors to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"condition_type": {
|
||||
"is_bat_low": "{entity_name} battery is low",
|
||||
@@ -321,36 +317,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Binary sensor",
|
||||
"triggers": {
|
||||
"occupancy_cleared": {
|
||||
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
|
||||
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Occupancy cleared"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
|
||||
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Occupancy detected"
|
||||
}
|
||||
}
|
||||
"title": "Binary sensor"
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Provides triggers for binary sensors."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from . import DOMAIN, BinarySensorDeviceClass
|
||||
|
||||
|
||||
def get_device_class_or_undefined(
|
||||
hass: HomeAssistant, entity_id: str
|
||||
) -> str | None | UndefinedType:
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
try:
|
||||
return get_device_class(hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
|
||||
"""Class for binary sensor on/off triggers."""
|
||||
|
||||
_device_class: BinarySensorDeviceClass | None
|
||||
_domains = {DOMAIN}
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if get_device_class_or_undefined(self._hass, entity_id)
|
||||
== self._device_class
|
||||
}
|
||||
|
||||
|
||||
def make_binary_sensor_trigger(
|
||||
device_class: BinarySensorDeviceClass | None,
|
||||
to_state: str,
|
||||
) -> type[BinarySensorOnOffTrigger]:
|
||||
"""Create an entity state trigger class."""
|
||||
|
||||
class CustomTrigger(BinarySensorOnOffTrigger):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_device_class = device_class
|
||||
_to_states = {to_state}
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"occupancy_detected": make_binary_sensor_trigger(
|
||||
BinarySensorDeviceClass.OCCUPANCY, STATE_ON
|
||||
),
|
||||
"occupancy_cleared": make_binary_sensor_trigger(
|
||||
BinarySensorDeviceClass.OCCUPANCY, STATE_OFF
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for binary sensors."""
|
||||
return TRIGGERS
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.9.1"
|
||||
"habluetooth==5.10.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
"name": "Entity"
|
||||
},
|
||||
"speed": {
|
||||
"description": "Fan Speed as %.",
|
||||
"name": "Fan Speed"
|
||||
"description": "The fan speed as a percentage.",
|
||||
"name": "Fan speed"
|
||||
}
|
||||
},
|
||||
"name": "Set fan speed tracked state"
|
||||
@@ -47,7 +47,7 @@
|
||||
"description": "Sets the tracked brightness state of a Bond light.",
|
||||
"fields": {
|
||||
"brightness": {
|
||||
"description": "Brightness.",
|
||||
"description": "The tracked brightness of the light.",
|
||||
"name": "Brightness"
|
||||
},
|
||||
"entity_id": {
|
||||
@@ -79,22 +79,22 @@
|
||||
"name": "Entity"
|
||||
},
|
||||
"power_state": {
|
||||
"description": "Power state.",
|
||||
"description": "The tracked power state.",
|
||||
"name": "Power state"
|
||||
}
|
||||
},
|
||||
"name": "Set switch power tracked state"
|
||||
},
|
||||
"start_decreasing_brightness": {
|
||||
"description": "Starts decreasing the brightness of the light (deprecated).",
|
||||
"description": "Starts decreasing the brightness of a light (deprecated).",
|
||||
"name": "Start decreasing brightness"
|
||||
},
|
||||
"start_increasing_brightness": {
|
||||
"description": "Starts increasing the brightness of the light (deprecated).",
|
||||
"description": "Starts increasing the brightness of a light (deprecated).",
|
||||
"name": "Start increasing brightness"
|
||||
},
|
||||
"stop": {
|
||||
"description": "Stops any in-progress action and empty the queue (deprecated).",
|
||||
"description": "Stops any in-progress action and empties the queue (deprecated).",
|
||||
"name": "[%key:common::action::stop%]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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,
|
||||
@@ -14,7 +15,7 @@ from . import DOMAIN
|
||||
class ButtonPressedTrigger(EntityTriggerBase):
|
||||
"""Trigger for button entity presses."""
|
||||
|
||||
_domains = {DOMAIN}
|
||||
_domain_specs = {DOMAIN: DomainSpec()}
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
|
||||
22
homeassistant/components/chess_com/diagnostics.py
Normal file
22
homeassistant/components/chess_com/diagnostics.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""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: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Can't detect a game
|
||||
|
||||
@@ -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_attribute_changed_trigger,
|
||||
make_entity_numerical_state_attribute_crossed_threshold_trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_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."""
|
||||
|
||||
_domains = {DOMAIN}
|
||||
_domain_specs = {DOMAIN: DomainSpec()}
|
||||
_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_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
"started_cooling": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
|
||||
),
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
"started_drying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
|
||||
),
|
||||
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
|
||||
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
|
||||
),
|
||||
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
|
||||
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
|
||||
),
|
||||
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
|
||||
"target_temperature_changed": make_entity_numerical_state_changed_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
|
||||
),
|
||||
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
|
||||
"target_temperature_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=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_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||
"started_heating": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -516,6 +516,8 @@ 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"
|
||||
@@ -572,6 +574,15 @@ 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==1.15.0", "openai==2.21.0"],
|
||||
"requirements": ["hass-nabucasa==2.0.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.0"]
|
||||
"requirements": ["aiocomelit==2.0.1"]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ 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},
|
||||
@@ -27,7 +26,6 @@ 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,81 +1,82 @@
|
||||
"""Provides triggers for covers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
get_device_class_or_undefined,
|
||||
)
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
|
||||
|
||||
class CoverTriggerBase(EntityTriggerBase):
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoverDomainSpec(DomainSpec):
|
||||
"""DomainSpec with a target value for comparison."""
|
||||
|
||||
target_value: str | bool | None = None
|
||||
|
||||
|
||||
class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]):
|
||||
"""Base trigger for cover state changes."""
|
||||
|
||||
_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 _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
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the state matches the target cover state."""
|
||||
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
|
||||
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
|
||||
return self._get_value(state) == domain_spec.target_value
|
||||
|
||||
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 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
|
||||
if (from_value := self._get_value(from_state)) is None:
|
||||
return False
|
||||
return from_value != self._get_value(to_state)
|
||||
|
||||
|
||||
def make_cover_opened_trigger(
|
||||
*, device_classes: dict[str, str], domains: set[str] | None = None
|
||||
*, device_classes: dict[str, str]
|
||||
) -> type[CoverTriggerBase]:
|
||||
"""Create a trigger cover_opened."""
|
||||
|
||||
class CoverOpenedTrigger(CoverTriggerBase):
|
||||
"""Trigger for cover opened state changes."""
|
||||
|
||||
_binary_sensor_target_state = STATE_ON
|
||||
_cover_is_closed_target_value = False
|
||||
_domains = domains or {DOMAIN}
|
||||
_device_classes = device_classes
|
||||
_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 CoverOpenedTrigger
|
||||
|
||||
|
||||
def make_cover_closed_trigger(
|
||||
*, device_classes: dict[str, str], domains: set[str] | None = None
|
||||
*, device_classes: dict[str, str]
|
||||
) -> type[CoverTriggerBase]:
|
||||
"""Create a trigger cover_closed."""
|
||||
|
||||
class CoverClosedTrigger(CoverTriggerBase):
|
||||
"""Trigger for cover closed state changes."""
|
||||
|
||||
_binary_sensor_target_state = STATE_OFF
|
||||
_cover_is_closed_target_value = True
|
||||
_domains = domains or {DOMAIN}
|
||||
_device_classes = device_classes
|
||||
_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 CoverClosedTrigger
|
||||
|
||||
|
||||
@@ -20,14 +20,8 @@ DEVICE_CLASSES_DOOR: dict[str, str] = {
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"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},
|
||||
),
|
||||
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_DOOR),
|
||||
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_DOOR),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .const import DOMAIN
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
|
||||
101
homeassistant/components/eheimdigital/binary_sensor.py
Normal file
101
homeassistant/components/eheimdigital/binary_sensor.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""EHEIM Digital binary sensors."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.reeflex import EheimDigitalReeflexUV
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
from .entity import EheimDigitalEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EheimDigitalBinarySensorDescription[_DeviceT: EheimDigitalDevice](
|
||||
BinarySensorEntityDescription
|
||||
):
|
||||
"""Class describing EHEIM Digital binary sensor entities."""
|
||||
|
||||
value_fn: Callable[[_DeviceT], bool | None]
|
||||
|
||||
|
||||
REEFLEX_DESCRIPTIONS: tuple[
|
||||
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV], ...
|
||||
] = (
|
||||
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
|
||||
key="is_lighting",
|
||||
translation_key="is_lighting",
|
||||
value_fn=lambda device: device.is_lighting,
|
||||
device_class=BinarySensorDeviceClass.LIGHT,
|
||||
),
|
||||
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
|
||||
key="is_uvc_connected",
|
||||
translation_key="is_uvc_connected",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda device: device.is_uvc_connected,
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EheimDigitalConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the callbacks for the coordinator so binary sensors can be added as devices are found."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def async_setup_device_entities(
|
||||
device_address: dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the binary sensor entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalBinarySensor[Any]] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalReeflexUV):
|
||||
entities += [
|
||||
EheimDigitalBinarySensor[EheimDigitalReeflexUV](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in REEFLEX_DESCRIPTIONS
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
coordinator.add_platform_callback(async_setup_device_entities)
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalBinarySensor[_DeviceT: EheimDigitalDevice](
|
||||
EheimDigitalEntity[_DeviceT], BinarySensorEntity
|
||||
):
|
||||
"""Represent an EHEIM Digital binary sensor entity."""
|
||||
|
||||
entity_description: EheimDigitalBinarySensorDescription[_DeviceT]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: _DeviceT,
|
||||
description: EheimDigitalBinarySensorDescription[_DeviceT],
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital binary sensor entity."""
|
||||
super().__init__(coordinator, device)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self._device_address}_{description.key}"
|
||||
|
||||
def _async_update_attrs(self) -> None:
|
||||
self._attr_is_on = self.entity_description.value_fn(self._device)
|
||||
@@ -1,5 +1,19 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"is_lighting": {
|
||||
"default": "mdi:lightbulb-outline",
|
||||
"state": {
|
||||
"on": "mdi:lightbulb-on"
|
||||
}
|
||||
},
|
||||
"is_uvc_connected": {
|
||||
"default": "mdi:lightbulb-off",
|
||||
"state": {
|
||||
"on": "mdi:lightbulb-outline"
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"day_speed": {
|
||||
"default": "mdi:weather-sunny"
|
||||
|
||||
@@ -33,6 +33,17 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"is_lighting": {
|
||||
"state": {
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"is_uvc_connected": {
|
||||
"name": "UVC lamp connected"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"heater": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.4.5"],
|
||||
"requirements": ["pyenphase==2.4.6"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==44.3.1",
|
||||
"aioesphomeapi==44.5.2",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.1"
|
||||
],
|
||||
|
||||
@@ -123,19 +123,13 @@ class EsphomeAssistSatelliteWakeWordSelect(
|
||||
|
||||
def __init__(self, entry_data: RuntimeEntryData, index: int = 0) -> None:
|
||||
"""Initialize a wake word selector."""
|
||||
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},
|
||||
)
|
||||
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)},
|
||||
)
|
||||
|
||||
EsphomeAssistEntity.__init__(self, entry_data)
|
||||
|
||||
|
||||
@@ -107,6 +107,12 @@
|
||||
"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": {
|
||||
@@ -116,11 +122,18 @@
|
||||
}
|
||||
},
|
||||
"wake_word": {
|
||||
"name": "Wake word{index}",
|
||||
"name": "Wake word",
|
||||
"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,7 +5,13 @@ from __future__ import annotations
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import EntityInfo, WaterHeaterInfo, WaterHeaterMode, WaterHeaterState
|
||||
from aioesphomeapi import (
|
||||
EntityInfo,
|
||||
WaterHeaterFeature,
|
||||
WaterHeaterInfo,
|
||||
WaterHeaterMode,
|
||||
WaterHeaterState,
|
||||
)
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
WaterHeaterEntity,
|
||||
@@ -54,6 +60,7 @@ 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
|
||||
@@ -63,6 +70,8 @@ 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
|
||||
@@ -101,6 +110,24 @@ 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,
|
||||
|
||||
@@ -363,10 +363,12 @@ class EvoController(EvoClimateEntity):
|
||||
|
||||
Data validation is not required, it will have been done upstream.
|
||||
"""
|
||||
if service == EvoService.SET_SYSTEM_MODE:
|
||||
mode = data[ATTR_MODE]
|
||||
else: # otherwise it is EvoService.RESET_SYSTEM
|
||||
mode = EvoSystemMode.AUTO_WITH_RESET
|
||||
|
||||
if service == EvoService.RESET_SYSTEM:
|
||||
await self.coordinator.call_client_api(self._evo_device.reset())
|
||||
return
|
||||
|
||||
mode = data[ATTR_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE
|
||||
|
||||
if ATTR_PERIOD in data:
|
||||
until = dt_util.start_of_local_day()
|
||||
|
||||
@@ -27,7 +27,6 @@ from .coordinator import EvoDataUpdateCoordinator
|
||||
# because supported modes can vary for edge-case systems
|
||||
|
||||
# Zone service schemas (registered as entity services)
|
||||
CLEAR_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {}
|
||||
SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
|
||||
vol.Required(ATTR_SETPOINT): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
|
||||
@@ -47,7 +46,7 @@ def _register_zone_entity_services(hass: HomeAssistant) -> None:
|
||||
DOMAIN,
|
||||
EvoService.CLEAR_ZONE_OVERRIDE,
|
||||
entity_domain=CLIMATE_DOMAIN,
|
||||
schema=CLEAR_ZONE_OVERRIDE_SCHEMA,
|
||||
schema=None,
|
||||
func="async_clear_zone_override",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
@@ -79,7 +78,6 @@ def setup_service_functions(
|
||||
@verify_domain_control(DOMAIN)
|
||||
async def set_system_mode(call: ServiceCall) -> None:
|
||||
"""Set the system mode."""
|
||||
assert coordinator.tcs is not None # mypy
|
||||
|
||||
payload = {
|
||||
"unique_id": coordinator.tcs.id,
|
||||
@@ -91,18 +89,11 @@ def setup_service_functions(
|
||||
assert coordinator.tcs is not None # mypy
|
||||
|
||||
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
|
||||
hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
|
||||
|
||||
# Enumerate which operating modes are supported by this system
|
||||
modes = list(coordinator.tcs.allowed_system_modes)
|
||||
|
||||
# Not all systems support "AutoWithReset": register this handler only if required
|
||||
if any(
|
||||
m[SZ_SYSTEM_MODE]
|
||||
for m in modes
|
||||
if m[SZ_SYSTEM_MODE] == EvoSystemMode.AUTO_WITH_RESET
|
||||
):
|
||||
hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
|
||||
|
||||
system_mode_schemas = []
|
||||
modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET]
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ def get_model_selection_schema(
|
||||
),
|
||||
vol.Required(
|
||||
CONF_BACKEND,
|
||||
default=options.get(CONF_BACKEND, "s1"),
|
||||
default=options.get(CONF_BACKEND, "s2-pro"),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
|
||||
@@ -31,7 +31,7 @@ TTS_SUPPORTED_LANGUAGES = [
|
||||
]
|
||||
|
||||
|
||||
BACKEND_MODELS = ["s1", "speech-1.5", "speech-1.6"]
|
||||
BACKEND_MODELS = ["s2-pro", "s1", "speech-1.5", "speech-1.6"]
|
||||
SORT_BY_OPTIONS = ["task_count", "score", "created_at"]
|
||||
LATENCY_OPTIONS = ["normal", "balanced"]
|
||||
|
||||
|
||||
@@ -103,6 +103,8 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Initialize flow from zeroconf."""
|
||||
zeroconf_properties = discovery_info.properties
|
||||
host = zeroconf_properties["api_domain"]
|
||||
port = zeroconf_properties["https_port"]
|
||||
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
|
||||
return await self.async_step_user({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"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."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["fritzconnection"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -29,9 +29,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: we are close to the goal of 95%
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -133,26 +133,20 @@ async def _async_wifi_entities_list(
|
||||
]
|
||||
)
|
||||
_LOGGER.debug("WiFi networks count: %s", wifi_count)
|
||||
networks: dict = {}
|
||||
networks: dict[int, dict[str, Any]] = {}
|
||||
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] = {
|
||||
"ssid": network_info["NewSSID"],
|
||||
"bssid": network_info["NewBSSID"],
|
||||
"standard": network_info["NewStandard"],
|
||||
"enabled": network_info["NewEnable"],
|
||||
"status": network_info["NewStatus"],
|
||||
}
|
||||
networks[i] = network_info
|
||||
for i, network in networks.copy().items():
|
||||
networks[i]["switch_name"] = network["ssid"]
|
||||
networks[i]["switch_name"] = network["NewSSID"]
|
||||
if (
|
||||
len(
|
||||
[
|
||||
j
|
||||
for j, n in networks.items()
|
||||
if slugify(n["ssid"]) == slugify(network["ssid"])
|
||||
if slugify(n["NewSSID"]) == slugify(network["NewSSID"])
|
||||
]
|
||||
)
|
||||
> 1
|
||||
@@ -434,13 +428,11 @@ 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) -> bool:
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
self.port_mapping["NewEnabled"] = "1" if turn_on else "0"
|
||||
|
||||
resp = await self._avm_wrapper.async_add_port_mapping(
|
||||
await self._avm_wrapper.async_add_port_mapping(
|
||||
self.connection_type, self.port_mapping
|
||||
)
|
||||
return bool(resp is not None)
|
||||
|
||||
|
||||
class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch):
|
||||
@@ -525,12 +517,11 @@ 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) -> bool:
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> None:
|
||||
"""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):
|
||||
@@ -541,10 +532,11 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
avm_wrapper: AvmWrapper,
|
||||
device_friendly_name: str,
|
||||
network_num: int,
|
||||
network_data: dict,
|
||||
network_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Init Fritz Wifi switch."""
|
||||
self._avm_wrapper = avm_wrapper
|
||||
self._wifi_info = network_data
|
||||
|
||||
self._attributes = {}
|
||||
self._attr_entity_category = EntityCategory.CONFIG
|
||||
@@ -560,7 +552,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["enabled"],
|
||||
init_state=network_data["NewEnable"],
|
||||
)
|
||||
super().__init__(self._avm_wrapper, device_friendly_name, switch_info)
|
||||
|
||||
@@ -587,7 +579,9 @@ 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,7 +179,9 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
return PRESET_HOLIDAY
|
||||
if self.data.summer_active:
|
||||
return PRESET_SUMMER
|
||||
if self.data.target_temperature == ON_API_TEMPERATURE:
|
||||
if self.data.target_temperature == ON_API_TEMPERATURE or getattr(
|
||||
self.data, "boost_active", False
|
||||
):
|
||||
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==20260304.0"]
|
||||
"requirements": ["home-assistant-frontend==20260312.0"]
|
||||
}
|
||||
|
||||
@@ -20,14 +20,8 @@ DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = {
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"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},
|
||||
),
|
||||
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
|
||||
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import CommunicationFailure
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
CommunicationFailure,
|
||||
)
|
||||
from gardena_bluetooth.parse import CharacteristicTime
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
@@ -23,6 +29,7 @@ from .coordinator import (
|
||||
GardenaBluetoothConfigEntry,
|
||||
GardenaBluetoothCoordinator,
|
||||
)
|
||||
from .util import async_get_product_type
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
@@ -51,22 +58,41 @@ def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
|
||||
return CachedConnection(DISCONNECT_DELAY, _device_lookup)
|
||||
|
||||
|
||||
async def _update_timestamp(client: Client, characteristics: CharacteristicTime):
|
||||
try:
|
||||
await client.update_timestamp(characteristics, dt_util.now())
|
||||
except CharacteristicNotFound:
|
||||
pass
|
||||
except CharacteristicNoAccess:
|
||||
LOGGER.debug("No access to update internal time")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Gardena Bluetooth from a config entry."""
|
||||
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
client = Client(get_connection(hass, address))
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
product_type = await async_get_product_type(hass, address)
|
||||
except TimeoutError as exception:
|
||||
raise ConfigEntryNotReady("Unable to find product type") from exception
|
||||
|
||||
client = Client(get_connection(hass, address), product_type)
|
||||
try:
|
||||
chars = await client.get_all_characteristics()
|
||||
|
||||
sw_version = await client.read_char(DeviceInformation.firmware_version, None)
|
||||
manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None)
|
||||
model = await client.read_char(DeviceInformation.model_number, None)
|
||||
name = await client.read_char(
|
||||
DeviceConfiguration.custom_device_name, entry.title
|
||||
)
|
||||
uuids = await client.get_all_characteristics_uuid()
|
||||
await client.update_timestamp(dt_util.now())
|
||||
|
||||
name = entry.title
|
||||
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
|
||||
|
||||
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
|
||||
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
await client.disconnect()
|
||||
raise ConfigEntryNotReady(
|
||||
@@ -83,7 +109,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
coordinator = GardenaBluetoothCoordinator(
|
||||
hass, entry, LOGGER, client, uuids, device, address
|
||||
hass, entry, LOGGER, client, set(chars.keys()), device, address
|
||||
)
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
@@ -34,14 +34,14 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothBinarySensorEntityDescription(
|
||||
key=Valve.connected_state.uuid,
|
||||
key=Valve.connected_state.unique_id,
|
||||
translation_key="valve_connected_state",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Valve.connected_state,
|
||||
),
|
||||
GardenaBluetoothBinarySensorEntityDescription(
|
||||
key=Sensor.connected_state.uuid,
|
||||
key=Sensor.connected_state.unique_id,
|
||||
translation_key="sensor_connected_state",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -60,7 +60,7 @@ async def async_setup_entry(
|
||||
entities = [
|
||||
GardenaBluetoothBinarySensor(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.key in coordinator.characteristics
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription):
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothButtonEntityDescription(
|
||||
key=Reset.factory_reset.uuid,
|
||||
key=Reset.factory_reset.unique_id,
|
||||
translation_key="factory_reset",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -49,7 +49,7 @@ async def async_setup_entry(
|
||||
entities = [
|
||||
GardenaBluetoothButton(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.key in coordinator.characteristics
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
|
||||
"requirements": ["gardena-bluetooth==1.6.0"]
|
||||
"requirements": ["gardena-bluetooth==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Valve.manual_watering_time.uuid,
|
||||
key=Valve.manual_watering_time.unique_id,
|
||||
translation_key="manual_watering_time",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -58,7 +58,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Valve.remaining_open_time.uuid,
|
||||
key=Valve.remaining_open_time.unique_id,
|
||||
translation_key="remaining_open_time",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
native_min_value=0.0,
|
||||
@@ -69,7 +69,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=DeviceConfiguration.rain_pause.uuid,
|
||||
key=DeviceConfiguration.rain_pause.unique_id,
|
||||
translation_key="rain_pause",
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -81,7 +81,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=DeviceConfiguration.seasonal_adjust.uuid,
|
||||
key=DeviceConfiguration.seasonal_adjust.unique_id,
|
||||
translation_key="seasonal_adjust",
|
||||
native_unit_of_measurement=UnitOfTime.DAYS,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -93,7 +93,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Sensor.threshold.uuid,
|
||||
key=Sensor.threshold.unique_id,
|
||||
translation_key="sensor_threshold",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -117,9 +117,9 @@ async def async_setup_entry(
|
||||
entities: list[NumberEntity] = [
|
||||
GardenaBluetoothNumber(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.key in coordinator.characteristics
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
if Valve.remaining_open_time.uuid in coordinator.characteristics:
|
||||
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
|
||||
entities.append(GardenaBluetoothRemainingOpenSetNumber(coordinator))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Valve.activation_reason.uuid,
|
||||
key=Valve.activation_reason.unique_id,
|
||||
translation_key="activation_reason",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -49,7 +49,7 @@ DESCRIPTIONS = (
|
||||
char=Valve.activation_reason,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Battery.battery_level.uuid,
|
||||
key=Battery.battery_level.unique_id,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -57,7 +57,7 @@ DESCRIPTIONS = (
|
||||
char=Battery.battery_level,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.battery_level.uuid,
|
||||
key=Sensor.battery_level.unique_id,
|
||||
translation_key="sensor_battery_level",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
@@ -67,7 +67,7 @@ DESCRIPTIONS = (
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.value.uuid,
|
||||
key=Sensor.value.unique_id,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.MOISTURE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
@@ -75,14 +75,14 @@ DESCRIPTIONS = (
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.type.uuid,
|
||||
key=Sensor.type.unique_id,
|
||||
translation_key="sensor_type",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Sensor.type,
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.measurement_timestamp.uuid,
|
||||
key=Sensor.measurement_timestamp.unique_id,
|
||||
translation_key="sensor_measurement_timestamp",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -102,9 +102,9 @@ async def async_setup_entry(
|
||||
entities: list[GardenaBluetoothEntity] = [
|
||||
GardenaBluetoothSensor(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.key in coordinator.characteristics
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
if Valve.remaining_open_time.uuid in coordinator.characteristics:
|
||||
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
|
||||
entities.append(GardenaBluetoothRemainSensor(coordinator))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
|
||||
"""Representation of a valve switch."""
|
||||
|
||||
characteristics = {
|
||||
Valve.state.uuid,
|
||||
Valve.manual_watering_time.uuid,
|
||||
Valve.remaining_open_time.uuid,
|
||||
Valve.state.unique_id,
|
||||
Valve.manual_watering_time.unique_id,
|
||||
Valve.remaining_open_time.unique_id,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -48,7 +48,7 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
|
||||
super().__init__(
|
||||
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
|
||||
self._attr_translation_key = "state"
|
||||
self._attr_is_on = None
|
||||
self._attr_entity_registry_enabled_default = False
|
||||
|
||||
51
homeassistant/components/gardena_bluetooth/util.py
Normal file
51
homeassistant/components/gardena_bluetooth/util.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Utility functions for Gardena Bluetooth integration."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from gardena_bluetooth.parse import ManufacturerData, ProductType
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
|
||||
|
||||
async def _async_service_info(
|
||||
hass, address
|
||||
) -> AsyncIterator[bluetooth.BluetoothServiceInfoBleak]:
|
||||
queue = asyncio.Queue[bluetooth.BluetoothServiceInfoBleak]()
|
||||
|
||||
def _callback(
|
||||
service_info: bluetooth.BluetoothServiceInfoBleak,
|
||||
change: bluetooth.BluetoothChange,
|
||||
) -> None:
|
||||
if change != bluetooth.BluetoothChange.ADVERTISEMENT:
|
||||
return
|
||||
|
||||
queue.put_nowait(service_info)
|
||||
|
||||
service_info = bluetooth.async_last_service_info(hass, address, True)
|
||||
if service_info:
|
||||
yield service_info
|
||||
|
||||
cancel = bluetooth.async_register_callback(
|
||||
hass,
|
||||
_callback,
|
||||
{bluetooth.match.ADDRESS: address},
|
||||
bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
yield await queue.get()
|
||||
finally:
|
||||
cancel()
|
||||
|
||||
|
||||
async def async_get_product_type(hass, address: str) -> ProductType:
|
||||
"""Wait for enough packets of manufacturer data to get the product type."""
|
||||
data = ManufacturerData()
|
||||
|
||||
async for service_info in _async_service_info(hass, address):
|
||||
data.update(service_info.manufacturer_data.get(ManufacturerData.company, b""))
|
||||
product_type = ProductType.from_manufacturer_data(data)
|
||||
if product_type is not ProductType.UNKNOWN:
|
||||
return product_type
|
||||
raise AssertionError("Iterator should have been infinite")
|
||||
@@ -44,9 +44,9 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
|
||||
_attr_device_class = ValveDeviceClass.WATER
|
||||
|
||||
characteristics = {
|
||||
Valve.state.uuid,
|
||||
Valve.manual_watering_time.uuid,
|
||||
Valve.remaining_open_time.uuid,
|
||||
Valve.state.unique_id,
|
||||
Valve.manual_watering_time.unique_id,
|
||||
Valve.remaining_open_time.unique_id,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -57,7 +57,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
|
||||
super().__init__(
|
||||
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self._attr_is_closed = not self.coordinator.get_cached(Valve.state)
|
||||
|
||||
17
homeassistant/components/gate/__init__.py
Normal file
17
homeassistant/components/gate/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Integration for gate triggers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DOMAIN = "gate"
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
__all__ = []
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
return True
|
||||
10
homeassistant/components/gate/icons.json
Normal file
10
homeassistant/components/gate/icons.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"trigger": "mdi:gate"
|
||||
},
|
||||
"opened": {
|
||||
"trigger": "mdi:gate-open"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
homeassistant/components/gate/manifest.json
Normal file
8
homeassistant/components/gate/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "gate",
|
||||
"name": "Gate",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/gate",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
38
homeassistant/components/gate/strings.json
Normal file
38
homeassistant/components/gate/strings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted gates to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Gate",
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"description": "Triggers after one or more gates close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::gate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::gate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gate closed"
|
||||
},
|
||||
"opened": {
|
||||
"description": "Triggers after one or more gates open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::gate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::gate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gate opened"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
homeassistant/components/gate/trigger.py
Normal file
25
homeassistant/components/gate/trigger.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Provides triggers for gates."""
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
CoverDeviceClass,
|
||||
make_cover_closed_trigger,
|
||||
make_cover_opened_trigger,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger
|
||||
|
||||
DEVICE_CLASSES_GATE: dict[str, str] = {
|
||||
COVER_DOMAIN: CoverDeviceClass.GATE,
|
||||
}
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GATE),
|
||||
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GATE),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for gates."""
|
||||
return TRIGGERS
|
||||
@@ -10,16 +10,16 @@
|
||||
- last
|
||||
- any
|
||||
|
||||
occupancy_cleared:
|
||||
closed:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
device_class: occupancy
|
||||
- domain: cover
|
||||
device_class: gate
|
||||
|
||||
occupancy_detected:
|
||||
opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
device_class: occupancy
|
||||
- domain: cover
|
||||
device_class: gate
|
||||
@@ -12,7 +12,7 @@ from homeassistant.helpers.helper_integration import (
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
)
|
||||
|
||||
from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS
|
||||
from .const import CONF_DUR_COOLDOWN, CONF_HEATER, CONF_MIN_DUR, CONF_SENSOR, PLATFORMS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,8 +91,13 @@ 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=2
|
||||
config_entry, options=options, minor_version=3
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -38,7 +39,9 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
DOMAIN as HOMEASSISTANT_DOMAIN,
|
||||
Context,
|
||||
CoreState,
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
@@ -46,27 +49,30 @@ from homeassistant.core import (
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import ConditionError
|
||||
from homeassistant.helpers import condition, config_validation as cv
|
||||
from homeassistant.helpers import 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,
|
||||
@@ -98,6 +104,8 @@ 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),
|
||||
@@ -167,6 +175,8 @@ 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)
|
||||
@@ -190,6 +200,8 @@ 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,
|
||||
@@ -221,6 +233,8 @@ 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,
|
||||
@@ -240,8 +254,16 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
heater_entity_id,
|
||||
)
|
||||
self.ac_mode = ac_mode
|
||||
self.min_cycle_duration = min_cycle_duration
|
||||
self.min_cycle_duration = min_cycle_duration or timedelta()
|
||||
self.max_cycle_duration = max_cycle_duration
|
||||
self.cycle_cooldown = cycle_cooldown or timedelta()
|
||||
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
|
||||
@@ -289,6 +311,7 @@ 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(
|
||||
@@ -482,6 +505,18 @@ 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
|
||||
@@ -517,57 +552,69 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
if not self._active or self._hvac_mode == HVACMode.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
|
||||
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
|
||||
|
||||
assert self._cur_temp is not None and self._target_temp is not None
|
||||
|
||||
min_temp = self._target_temp - self._cold_tolerance
|
||||
max_temp = self._target_temp + self._hot_tolerance
|
||||
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()
|
||||
|
||||
if self._is_device_active:
|
||||
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()
|
||||
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,
|
||||
)
|
||||
elif time is not None:
|
||||
# The time argument is passed only in keep-alive case
|
||||
# This is a keep-alive call, so ensure it's on
|
||||
_LOGGER.debug(
|
||||
"Keep-alive - Turning on heater heater %s",
|
||||
"Keep-alive - Turning on 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.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 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 time is not None:
|
||||
# The time argument is passed only in keep-alive case
|
||||
# This is a keep-alive call, so ensure it's off
|
||||
_LOGGER.debug(
|
||||
"Keep-alive - Turning off heater %s", self.heater_entity_id
|
||||
)
|
||||
await self._async_heater_turn_off()
|
||||
await self._async_heater_turn_off(keepalive=True)
|
||||
|
||||
@property
|
||||
def _is_device_active(self) -> bool | None:
|
||||
@@ -577,19 +624,48 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
return self.hass.states.is_state(self.heater_entity_id, STATE_ON)
|
||||
|
||||
async def _async_heater_turn_on(self) -> None:
|
||||
async def _async_heater_turn_on(self, keepalive: bool = False) -> 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=self._context
|
||||
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=new_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) -> None:
|
||||
async def _async_heater_turn_off(self, keepalive: bool = False) -> 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=self._context
|
||||
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=new_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."""
|
||||
@@ -613,3 +689,30 @@ 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,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -12,16 +13,20 @@ 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,
|
||||
@@ -63,6 +68,12 @@ 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
|
||||
@@ -90,13 +101,31 @@ 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), next_step="presets"),
|
||||
"init": SchemaFlowFormStep(
|
||||
vol.Schema(OPTIONS_SCHEMA),
|
||||
validate_user_input=_validate_config,
|
||||
next_step="presets",
|
||||
),
|
||||
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
|
||||
}
|
||||
|
||||
@@ -104,7 +133,7 @@ OPTIONS_FLOW = {
|
||||
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
"""Handle a config or options flow."""
|
||||
|
||||
MINOR_VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
|
||||
config_flow = CONFIG_FLOW
|
||||
options_flow = OPTIONS_FLOW
|
||||
|
||||
@@ -20,6 +20,8 @@ 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,11 +16,13 @@
|
||||
"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 cycle duration",
|
||||
"min_cycle_duration": "Minimum run time",
|
||||
"min_temp": "Minimum target temperature",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"target_sensor": "Temperature sensor"
|
||||
@@ -28,10 +30,12 @@
|
||||
"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. 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.",
|
||||
"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.",
|
||||
"target_sensor": "Temperature sensor that reflects the current temperature."
|
||||
},
|
||||
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
|
||||
@@ -40,14 +44,19 @@
|
||||
}
|
||||
},
|
||||
"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%]",
|
||||
@@ -56,9 +65,11 @@
|
||||
"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%]"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioghost"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["aioghost==0.4.0"]
|
||||
}
|
||||
|
||||
@@ -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.0"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.2"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user