mirror of
https://github.com/home-assistant/core.git
synced 2025-12-01 13:38:03 +00:00
Compare commits
228 Commits
echo
...
sort-commo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ae036a7e2 | ||
|
|
fd47d6578e | ||
|
|
df6a5d7459 | ||
|
|
b8a0cdea12 | ||
|
|
570e11ba5b | ||
|
|
19704cff04 | ||
|
|
51c09c2aa4 | ||
|
|
ef46552146 | ||
|
|
75533463f7 | ||
|
|
2cd496fdaf | ||
|
|
cd4c79450b | ||
|
|
a1d1f6ec97 | ||
|
|
a910fb879c | ||
|
|
4e904bf5a3 | ||
|
|
38cc26485a | ||
|
|
2bba185e4c | ||
|
|
743cc42829 | ||
|
|
f3021b40ab | ||
|
|
9ec9110e1e | ||
|
|
433c2cb43e | ||
|
|
fcffe5151d | ||
|
|
ca1677cc46 | ||
|
|
27f7085b61 | ||
|
|
f607b95c00 | ||
|
|
72502c1a15 | ||
|
|
47e78e9008 | ||
|
|
1fb51ef189 | ||
|
|
f96e31fad8 | ||
|
|
e99bf21a36 | ||
|
|
3059d06960 | ||
|
|
2b55f3af36 | ||
|
|
776501f5e6 | ||
|
|
1f93d2cefb | ||
|
|
1633700a58 | ||
|
|
923ec71bf6 | ||
|
|
7566046995 | ||
|
|
b9dbf07a5e | ||
|
|
b8b153b87f | ||
|
|
d4dd8fd902 | ||
|
|
a3bc55f49b | ||
|
|
7ba94a680d | ||
|
|
664e09790c | ||
|
|
d45fce86a9 | ||
|
|
507c0739df | ||
|
|
d7301c62e2 | ||
|
|
befed910da | ||
|
|
2509353221 | ||
|
|
694a77fe3c | ||
|
|
bc7f5f3981 | ||
|
|
cea5cda881 | ||
|
|
9e063fd77c | ||
|
|
01fb6841da | ||
|
|
48d3dd88a1 | ||
|
|
051cc41d4f | ||
|
|
661b55d6eb | ||
|
|
d197acc069 | ||
|
|
bf190a8a73 | ||
|
|
c386abd49d | ||
|
|
6342d8334b | ||
|
|
24bb13e0d1 | ||
|
|
212c42ca77 | ||
|
|
54843bb422 | ||
|
|
c115a7f455 | ||
|
|
597c0ab985 | ||
|
|
b86bb75e5e | ||
|
|
b662d32e44 | ||
|
|
72f690d681 | ||
|
|
33c9f3cc7d | ||
|
|
a1076300c8 | ||
|
|
dc92e912c2 | ||
|
|
2451e5578a | ||
|
|
1c83dab0a1 | ||
|
|
b42973040c | ||
|
|
6507955a14 | ||
|
|
79dbc70470 | ||
|
|
2bab7436d3 | ||
|
|
60479369b6 | ||
|
|
ec3f5561dc | ||
|
|
2e5f56b70d | ||
|
|
461039f06a | ||
|
|
351e594fe4 | ||
|
|
377da5f954 | ||
|
|
51a881f3b5 | ||
|
|
5025e31129 | ||
|
|
f98720e525 | ||
|
|
37240e811b | ||
|
|
0b7a023d2e | ||
|
|
beec67a247 | ||
|
|
571349e3a2 | ||
|
|
d9eb248e91 | ||
|
|
fc8affd243 | ||
|
|
4d6fd1b10f | ||
|
|
257242e6e3 | ||
|
|
7f494c235c | ||
|
|
8c42db7501 | ||
|
|
183bbcd1e1 | ||
|
|
8c4b8028cf | ||
|
|
ea1045d826 | ||
|
|
db5bf41790 | ||
|
|
580c6f2684 | ||
|
|
d62c18c225 | ||
|
|
8f9f9bc8e7 | ||
|
|
6ad6e82a23 | ||
|
|
3d507c7b44 | ||
|
|
4f5c7353f8 | ||
|
|
0b961d98f5 | ||
|
|
1cd82ab8ee | ||
|
|
cc792403ab | ||
|
|
3d2ab3b59e | ||
|
|
ba1650bd05 | ||
|
|
df5f6fc1e6 | ||
|
|
0dbdb42947 | ||
|
|
325022ec77 | ||
|
|
3ea1d2823e | ||
|
|
83d9c000d3 | ||
|
|
266612e4d9 | ||
|
|
dc7cba60bd | ||
|
|
d752a3a24c | ||
|
|
8c3ee80203 | ||
|
|
94555f533b | ||
|
|
6da33a8883 | ||
|
|
d42e31b5e7 | ||
|
|
441917706b | ||
|
|
12e530dc75 | ||
|
|
59651c6f10 | ||
|
|
ac21d2855c | ||
|
|
6070feea73 | ||
|
|
167881e434 | ||
|
|
35bcf82627 | ||
|
|
66bb501621 | ||
|
|
179ba8309d | ||
|
|
2b7543aca2 | ||
|
|
1e49e04491 | ||
|
|
e60b6482ab | ||
|
|
7b82781f4c | ||
|
|
b40daf0152 | ||
|
|
417ac56bd6 | ||
|
|
c9a0814142 | ||
|
|
2d8a619b54 | ||
|
|
759cc3303a | ||
|
|
5328429b08 | ||
|
|
21b98a76cc | ||
|
|
95f632a13a | ||
|
|
33d4d1f8e5 | ||
|
|
72878c18d0 | ||
|
|
ccd220ad0f | ||
|
|
f191f6ae22 | ||
|
|
28a18e538d | ||
|
|
c2f6255d16 | ||
|
|
e5fd08ae76 | ||
|
|
4b5633d9d8 | ||
|
|
a9c6a06704 | ||
|
|
0faa8efd5a | ||
|
|
5a257b090e | ||
|
|
41fb6a537f | ||
|
|
b166c32eb8 | ||
|
|
288acfb511 | ||
|
|
2cb9682303 | ||
|
|
7e52170789 | ||
|
|
979b3d4269 | ||
|
|
9772014bce | ||
|
|
f8763c49ef | ||
|
|
b4ef00659c | ||
|
|
df49c53bb6 | ||
|
|
8dfe483b38 | ||
|
|
b45d7cbbc3 | ||
|
|
239ba9b1cc | ||
|
|
2d5a75d4f2 | ||
|
|
e1ad3f05e6 | ||
|
|
b9280edbfa | ||
|
|
010993fc5f | ||
|
|
713931661e | ||
|
|
af06521f66 | ||
|
|
c32f57f85a | ||
|
|
171061a778 | ||
|
|
476ea35bdb | ||
|
|
00e6866664 | ||
|
|
201bf95ab8 | ||
|
|
ff22bbd0e4 | ||
|
|
fd8d4e937c | ||
|
|
7903348d79 | ||
|
|
090dbba06e | ||
|
|
af77e69eb0 | ||
|
|
23e7638687 | ||
|
|
36b722960a | ||
|
|
3dd241a398 | ||
|
|
b5a9c3d1f6 | ||
|
|
eca714a45a | ||
|
|
8049699efb | ||
|
|
7c6afd50dc | ||
|
|
42d8889778 | ||
|
|
a4c0304e1f | ||
|
|
c63e688ba8 | ||
|
|
16298b4195 | ||
|
|
da23eb22db | ||
|
|
4bd1d0199b | ||
|
|
efe7050030 | ||
|
|
79ff85f517 | ||
|
|
73ad4caf94 | ||
|
|
e3d649d349 | ||
|
|
657e3488ba | ||
|
|
7508c14a53 | ||
|
|
ac84970da8 | ||
|
|
30073f3493 | ||
|
|
3abd7b8ba3 | ||
|
|
62bc6e4bf6 | ||
|
|
5faa189fef | ||
|
|
e09ae1c83d | ||
|
|
7b20299de7 | ||
|
|
81e501aba1 | ||
|
|
568ac22ce8 | ||
|
|
c71ab054f1 | ||
|
|
bea201f9f6 | ||
|
|
dda90bc04c | ||
|
|
a033e4c88d | ||
|
|
42b6f83e7c | ||
|
|
cb937bc115 | ||
|
|
bec569caf9 | ||
|
|
3390fb32a8 | ||
|
|
3ebb58f780 | ||
|
|
30b131d3b9 | ||
|
|
cd40232beb | ||
|
|
f27fe365c5 | ||
|
|
1c769418fb | ||
|
|
db7c2dab52 | ||
|
|
627377872b | ||
|
|
8504162539 | ||
|
|
67c6a1d436 |
18
.github/workflows/builder.yml
vendored
18
.github/workflows/builder.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
@@ -448,6 +448,9 @@ jobs:
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
@@ -473,16 +476,13 @@ jobs:
|
||||
run: |
|
||||
# Remove dist, build, and homeassistant.egg-info
|
||||
# when build locally for testing!
|
||||
pip install twine build
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- name: Upload package
|
||||
shell: bash
|
||||
run: |
|
||||
export TWINE_USERNAME="__token__"
|
||||
export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}"
|
||||
|
||||
twine upload dist/* --skip-existing
|
||||
- name: Upload package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@v1.12.4
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
hassfest-image:
|
||||
name: Build and test hassfest image
|
||||
|
||||
22
.github/workflows/ci.yaml
vendored
22
.github/workflows/ci.yaml
vendored
@@ -537,7 +537,7 @@ jobs:
|
||||
python --version
|
||||
uv pip freeze >> pip_freeze.txt
|
||||
- name: Upload pip_freeze artifact
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pip-freeze-${{ matrix.python-version }}
|
||||
path: pip_freeze.txt
|
||||
@@ -661,7 +661,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json
|
||||
- name: Upload licenses
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
|
||||
path: licenses-${{ matrix.python-version }}.json
|
||||
@@ -877,7 +877,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests
|
||||
- name: Upload pytest_buckets
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest_buckets
|
||||
path: pytest_buckets.txt
|
||||
@@ -980,14 +980,14 @@ jobs:
|
||||
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-full.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: pytest-*.txt
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
@@ -1108,7 +1108,7 @@ jobs:
|
||||
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
@@ -1116,7 +1116,7 @@ jobs:
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
@@ -1239,7 +1239,7 @@ jobs:
|
||||
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
@@ -1247,7 +1247,7 @@ jobs:
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
@@ -1382,14 +1382,14 @@ jobs:
|
||||
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: pytest-*.txt
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.28.9
|
||||
uses: github/codeql-action/init@v3.28.10
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.28.9
|
||||
uses: github/codeql-action/analyze@v3.28.10
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
8
.github/workflows/wheels.yml
vendored
8
.github/workflows/wheels.yml
vendored
@@ -91,7 +91,7 @@ jobs:
|
||||
) > build_constraints.txt
|
||||
|
||||
- name: Upload env_file
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: env_file
|
||||
path: ./.env_file
|
||||
@@ -99,14 +99,14 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
- name: Upload build_constraints
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: build_constraints
|
||||
path: ./build_constraints.txt
|
||||
overwrite: true
|
||||
|
||||
- name: Upload requirements_diff
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: requirements_diff
|
||||
path: ./requirements_diff.txt
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
python -m script.gen_requirements_all ci
|
||||
|
||||
- name: Upload requirements_all_wheels
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
path: ./requirements_all_wheels_*.txt
|
||||
|
||||
@@ -103,6 +103,7 @@ homeassistant.components.auth.*
|
||||
homeassistant.components.automation.*
|
||||
homeassistant.components.awair.*
|
||||
homeassistant.components.axis.*
|
||||
homeassistant.components.azure_storage.*
|
||||
homeassistant.components.backup.*
|
||||
homeassistant.components.baf.*
|
||||
homeassistant.components.bang_olufsen.*
|
||||
@@ -407,6 +408,7 @@ homeassistant.components.raspberry_pi.*
|
||||
homeassistant.components.rdw.*
|
||||
homeassistant.components.recollect_waste.*
|
||||
homeassistant.components.recorder.*
|
||||
homeassistant.components.remember_the_milk.*
|
||||
homeassistant.components.remote.*
|
||||
homeassistant.components.renault.*
|
||||
homeassistant.components.reolink.*
|
||||
|
||||
18
CODEOWNERS
generated
18
CODEOWNERS
generated
@@ -180,6 +180,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/azure_event_hub/ @eavanvalkenburg
|
||||
/tests/components/azure_event_hub/ @eavanvalkenburg
|
||||
/homeassistant/components/azure_service_bus/ @hfurubotten
|
||||
/homeassistant/components/azure_storage/ @zweckj
|
||||
/tests/components/azure_storage/ @zweckj
|
||||
/homeassistant/components/backup/ @home-assistant/core
|
||||
/tests/components/backup/ @home-assistant/core
|
||||
/homeassistant/components/baf/ @bdraco @jfroy
|
||||
@@ -967,8 +969,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/motionblinds_ble/ @LennP @jerrybboy
|
||||
/homeassistant/components/motioneye/ @dermotduffy
|
||||
/tests/components/motioneye/ @dermotduffy
|
||||
/homeassistant/components/motionmount/ @RJPoelstra
|
||||
/tests/components/motionmount/ @RJPoelstra
|
||||
/homeassistant/components/motionmount/ @laiho-vogels
|
||||
/tests/components/motionmount/ @laiho-vogels
|
||||
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
|
||||
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco
|
||||
/homeassistant/components/msteams/ @peroyvind
|
||||
@@ -1051,8 +1053,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/numato/ @clssn
|
||||
/homeassistant/components/number/ @home-assistant/core @Shulyaka
|
||||
/tests/components/number/ @home-assistant/core @Shulyaka
|
||||
/homeassistant/components/nut/ @bdraco @ollo69 @pestevez
|
||||
/tests/components/nut/ @bdraco @ollo69 @pestevez
|
||||
/homeassistant/components/nut/ @bdraco @ollo69 @pestevez @tdfountain
|
||||
/tests/components/nut/ @bdraco @ollo69 @pestevez @tdfountain
|
||||
/homeassistant/components/nws/ @MatthewFlamm @kamiyo
|
||||
/tests/components/nws/ @MatthewFlamm @kamiyo
|
||||
/homeassistant/components/nyt_games/ @joostlek
|
||||
@@ -1144,8 +1146,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/philips_js/ @elupus
|
||||
/homeassistant/components/pi_hole/ @shenxn
|
||||
/tests/components/pi_hole/ @shenxn
|
||||
/homeassistant/components/picnic/ @corneyl
|
||||
/tests/components/picnic/ @corneyl
|
||||
/homeassistant/components/picnic/ @corneyl @codesalatdev
|
||||
/tests/components/picnic/ @corneyl @codesalatdev
|
||||
/homeassistant/components/ping/ @jpbede
|
||||
/tests/components/ping/ @jpbede
|
||||
/homeassistant/components/plaato/ @JohNan
|
||||
@@ -1413,6 +1415,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/snapcast/ @luar123
|
||||
/homeassistant/components/snmp/ @nmaggioni
|
||||
/tests/components/snmp/ @nmaggioni
|
||||
/homeassistant/components/snoo/ @Lash-L
|
||||
/tests/components/snoo/ @Lash-L
|
||||
/homeassistant/components/snooz/ @AustinBrunkhorst
|
||||
/tests/components/snooz/ @AustinBrunkhorst
|
||||
/homeassistant/components/solaredge/ @frenck @bdraco
|
||||
@@ -1693,6 +1697,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/weatherflow_cloud/ @jeeftor
|
||||
/homeassistant/components/weatherkit/ @tjhorner
|
||||
/tests/components/weatherkit/ @tjhorner
|
||||
/homeassistant/components/webdav/ @jpbede
|
||||
/tests/components/webdav/ @jpbede
|
||||
/homeassistant/components/webhook/ @home-assistant/core
|
||||
/tests/components/webhook/ @home-assistant/core
|
||||
/homeassistant/components/webmin/ @autinerd
|
||||
|
||||
@@ -74,6 +74,7 @@ from .core_config import async_process_ha_core_config
|
||||
from .exceptions import HomeAssistantError
|
||||
from .helpers import (
|
||||
area_registry,
|
||||
backup,
|
||||
category_registry,
|
||||
config_validation as cv,
|
||||
device_registry,
|
||||
@@ -163,16 +164,6 @@ FRONTEND_INTEGRATIONS = {
|
||||
# integrations can be removed and database migration status is
|
||||
# visible in frontend
|
||||
"frontend",
|
||||
# Hassio is an after dependency of backup, after dependencies
|
||||
# are not promoted from stage 2 to earlier stages, so we need to
|
||||
# add it here. Hassio needs to be setup before backup, otherwise
|
||||
# the backup integration will think we are a container/core install
|
||||
# when using HAOS or Supervised install.
|
||||
"hassio",
|
||||
# Backup is an after dependency of frontend, after dependencies
|
||||
# are not promoted from stage 2 to earlier stages, so we need to
|
||||
# add it here.
|
||||
"backup",
|
||||
}
|
||||
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
|
||||
# The substage containing recorder should have no timeout, as it could cancel a database migration.
|
||||
@@ -206,6 +197,8 @@ STAGE_1_INTEGRATIONS = {
|
||||
"mqtt_eventstream",
|
||||
# To provide account link implementations
|
||||
"cloud",
|
||||
# Ensure supervisor is available
|
||||
"hassio",
|
||||
}
|
||||
|
||||
DEFAULT_INTEGRATIONS = {
|
||||
@@ -328,10 +321,10 @@ async def async_setup_hass(
|
||||
|
||||
block_async_io.enable()
|
||||
|
||||
config_dict = None
|
||||
basic_setup_success = False
|
||||
|
||||
if not (recovery_mode := runtime_config.recovery_mode):
|
||||
config_dict = None
|
||||
basic_setup_success = False
|
||||
|
||||
await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass)
|
||||
|
||||
try:
|
||||
@@ -349,39 +342,43 @@ async def async_setup_hass(
|
||||
await async_from_config_dict(config_dict, hass) is not None
|
||||
)
|
||||
|
||||
if config_dict is None:
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
if config_dict is None:
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
|
||||
elif not basic_setup_success:
|
||||
_LOGGER.warning("Unable to set up core integrations. Activating recovery mode")
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
elif not basic_setup_success:
|
||||
_LOGGER.warning(
|
||||
"Unable to set up core integrations. Activating recovery mode"
|
||||
)
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
|
||||
elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS):
|
||||
_LOGGER.warning(
|
||||
"Detected that %s did not load. Activating recovery mode",
|
||||
",".join(CRITICAL_INTEGRATIONS),
|
||||
)
|
||||
elif any(
|
||||
domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Detected that %s did not load. Activating recovery mode",
|
||||
",".join(CRITICAL_INTEGRATIONS),
|
||||
)
|
||||
|
||||
old_config = hass.config
|
||||
old_logging = hass.data.get(DATA_LOGGING)
|
||||
old_config = hass.config
|
||||
old_logging = hass.data.get(DATA_LOGGING)
|
||||
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
|
||||
if old_logging:
|
||||
hass.data[DATA_LOGGING] = old_logging
|
||||
hass.config.debug = old_config.debug
|
||||
hass.config.skip_pip = old_config.skip_pip
|
||||
hass.config.skip_pip_packages = old_config.skip_pip_packages
|
||||
hass.config.internal_url = old_config.internal_url
|
||||
hass.config.external_url = old_config.external_url
|
||||
# Setup loader cache after the config dir has been set
|
||||
loader.async_setup(hass)
|
||||
if old_logging:
|
||||
hass.data[DATA_LOGGING] = old_logging
|
||||
hass.config.debug = old_config.debug
|
||||
hass.config.skip_pip = old_config.skip_pip
|
||||
hass.config.skip_pip_packages = old_config.skip_pip_packages
|
||||
hass.config.internal_url = old_config.internal_url
|
||||
hass.config.external_url = old_config.external_url
|
||||
# Setup loader cache after the config dir has been set
|
||||
loader.async_setup(hass)
|
||||
|
||||
if recovery_mode:
|
||||
_LOGGER.info("Starting in recovery mode")
|
||||
@@ -901,6 +898,10 @@ async def _async_set_up_integrations(
|
||||
if "recorder" in domains_to_setup:
|
||||
recorder.async_initialize_recorder(hass)
|
||||
|
||||
# Initialize backup
|
||||
if "backup" in domains_to_setup:
|
||||
backup.async_initialize_backup(hass)
|
||||
|
||||
stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [
|
||||
*(
|
||||
(name, domain_group & domains_to_setup, timeout)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"azure_devops",
|
||||
"azure_event_hub",
|
||||
"azure_service_bus",
|
||||
"azure_storage",
|
||||
"microsoft_face_detect",
|
||||
"microsoft_face_identify",
|
||||
"microsoft_face",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["adext", "alarmdecoder"],
|
||||
"requirements": ["adext==0.4.3"]
|
||||
"requirements": ["adext==0.4.4"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["androidtvremote2"],
|
||||
"requirements": ["androidtvremote2==0.1.2"],
|
||||
"requirements": ["androidtvremote2==0.2.0"],
|
||||
"zeroconf": ["_androidtvremote2._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.44.0"]
|
||||
"requirements": ["anthropic==0.47.2"]
|
||||
}
|
||||
|
||||
82
homeassistant/components/azure_storage/__init__.py
Normal file
82
homeassistant/components/azure_storage/__init__.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""The Azure Storage integration."""
|
||||
|
||||
from aiohttp import ClientTimeout
|
||||
from azure.core.exceptions import (
|
||||
ClientAuthenticationError,
|
||||
HttpResponseError,
|
||||
ResourceNotFoundError,
|
||||
)
|
||||
from azure.core.pipeline.transport._aiohttp import (
|
||||
AioHttpTransport,
|
||||
) # need to import from private file, as it is not properly imported in the init
|
||||
from azure.storage.blob.aio import ContainerClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_ACCOUNT_NAME,
|
||||
CONF_CONTAINER_NAME,
|
||||
CONF_STORAGE_ACCOUNT_KEY,
|
||||
DATA_BACKUP_AGENT_LISTENERS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
type AzureStorageConfigEntry = ConfigEntry[ContainerClient]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AzureStorageConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Azure Storage integration."""
|
||||
# set increase aiohttp timeout for long running operations (up/download)
|
||||
session = async_create_clientsession(
|
||||
hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60)
|
||||
)
|
||||
container_client = ContainerClient(
|
||||
account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
container_name=entry.data[CONF_CONTAINER_NAME],
|
||||
credential=entry.data[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=session),
|
||||
)
|
||||
|
||||
try:
|
||||
if not await container_client.exists():
|
||||
await container_client.create_container()
|
||||
except ResourceNotFoundError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="account_not_found",
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
) from err
|
||||
except ClientAuthenticationError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
) from err
|
||||
except HttpResponseError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
) from err
|
||||
|
||||
entry.runtime_data = container_client
|
||||
|
||||
def _async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(_async_notify_backup_listeners))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: AzureStorageConfigEntry
|
||||
) -> bool:
|
||||
"""Unload an Azure Storage config entry."""
|
||||
return True
|
||||
182
homeassistant/components/azure_storage/backup.py
Normal file
182
homeassistant/components/azure_storage/backup.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Support for Azure Storage backup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from azure.core.exceptions import HttpResponseError
|
||||
from azure.storage.blob import BlobProperties
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import AzureStorageConfigEntry
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
METADATA_VERSION = "1"
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
hass: HomeAssistant,
|
||||
) -> list[BackupAgent]:
|
||||
"""Return a list of backup agents."""
|
||||
entries: list[AzureStorageConfigEntry] = hass.config_entries.async_loaded_entries(
|
||||
DOMAIN
|
||||
)
|
||||
return [AzureStorageBackupAgent(hass, entry) for entry in entries]
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_backup_agents_listener(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
listener: Callable[[], None],
|
||||
**kwargs: Any,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a listener to be called when agents are added or removed."""
|
||||
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove the listener."""
|
||||
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
|
||||
if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
|
||||
hass.data.pop(DATA_BACKUP_AGENT_LISTENERS)
|
||||
|
||||
return remove_listener
|
||||
|
||||
|
||||
def handle_backup_errors[_R, **P](
|
||||
func: Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]],
|
||||
) -> Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]]:
|
||||
"""Handle backup errors."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(
|
||||
self: AzureStorageBackupAgent, *args: P.args, **kwargs: P.kwargs
|
||||
) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except HttpResponseError as err:
|
||||
_LOGGER.debug(
|
||||
"Error during backup in %s: Status %s, message %s",
|
||||
func.__name__,
|
||||
err.status_code,
|
||||
err.message,
|
||||
exc_info=True,
|
||||
)
|
||||
raise BackupAgentError(
|
||||
f"Error during backup operation in {func.__name__}:"
|
||||
f" Status {err.status_code}, message: {err.message}"
|
||||
) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class AzureStorageBackupAgent(BackupAgent):
|
||||
"""Azure storage backup agent."""
|
||||
|
||||
domain = DOMAIN
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: AzureStorageConfigEntry) -> None:
|
||||
"""Initialize the Azure storage backup agent."""
|
||||
super().__init__()
|
||||
self._client = entry.runtime_data
|
||||
self.name = entry.title
|
||||
self.unique_id = entry.entry_id
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_download_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Download a backup file."""
|
||||
blob = await self._find_blob_by_backup_id(backup_id)
|
||||
if blob is None:
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
download_stream = await self._client.download_blob(blob.name)
|
||||
return download_stream.chunks()
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_upload_backup(
|
||||
self,
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
|
||||
metadata = {
|
||||
"metadata_version": METADATA_VERSION,
|
||||
"backup_id": backup.backup_id,
|
||||
"backup_metadata": json.dumps(backup.as_dict()),
|
||||
}
|
||||
|
||||
await self._client.upload_blob(
|
||||
name=suggested_filename(backup),
|
||||
metadata=metadata,
|
||||
data=await open_stream(),
|
||||
length=backup.size,
|
||||
)
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_delete_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Delete a backup file."""
|
||||
blob = await self._find_blob_by_backup_id(backup_id)
|
||||
if blob is None:
|
||||
return
|
||||
await self._client.delete_blob(blob.name)
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
backups: list[AgentBackup] = []
|
||||
async for blob in self._client.list_blobs(include="metadata"):
|
||||
metadata = blob.metadata
|
||||
|
||||
if metadata.get("metadata_version") == METADATA_VERSION:
|
||||
backups.append(
|
||||
AgentBackup.from_dict(json.loads(metadata["backup_metadata"]))
|
||||
)
|
||||
|
||||
return backups
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_get_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup | None:
|
||||
"""Return a backup."""
|
||||
blob = await self._find_blob_by_backup_id(backup_id)
|
||||
if blob is None:
|
||||
return None
|
||||
|
||||
return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"]))
|
||||
|
||||
async def _find_blob_by_backup_id(self, backup_id: str) -> BlobProperties | None:
|
||||
"""Find a blob by backup id."""
|
||||
async for blob in self._client.list_blobs(include="metadata"):
|
||||
if (
|
||||
backup_id == blob.metadata.get("backup_id", "")
|
||||
and blob.metadata.get("metadata_version") == METADATA_VERSION
|
||||
):
|
||||
return blob
|
||||
return None
|
||||
72
homeassistant/components/azure_storage/config_flow.py
Normal file
72
homeassistant/components/azure_storage/config_flow.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Config flow for Azure Storage integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError
|
||||
from azure.core.pipeline.transport._aiohttp import (
|
||||
AioHttpTransport,
|
||||
) # need to import from private file, as it is not properly imported in the init
|
||||
from azure.storage.blob.aio import ContainerClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_ACCOUNT_NAME,
|
||||
CONF_CONTAINER_NAME,
|
||||
CONF_STORAGE_ACCOUNT_KEY,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for azure storage."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""User step for Azure Storage."""
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]}
|
||||
)
|
||||
container_client = ContainerClient(
|
||||
account_url=f"https://{user_input[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
container_name=user_input[CONF_CONTAINER_NAME],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
)
|
||||
try:
|
||||
await container_client.exists()
|
||||
except ResourceNotFoundError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except ClientAuthenticationError:
|
||||
errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown exception occurred")
|
||||
errors["base"] = "unknown"
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=f"{user_input[CONF_ACCOUNT_NAME]}/{user_input[CONF_CONTAINER_NAME]}",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACCOUNT_NAME): str,
|
||||
vol.Required(
|
||||
CONF_CONTAINER_NAME, default="home-assistant-backups"
|
||||
): str,
|
||||
vol.Required(CONF_STORAGE_ACCOUNT_KEY): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
16
homeassistant/components/azure_storage/const.py
Normal file
16
homeassistant/components/azure_storage/const.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Constants for the Azure Storage integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN: Final = "azure_storage"
|
||||
|
||||
CONF_STORAGE_ACCOUNT_KEY: Final = "storage_account_key"
|
||||
CONF_ACCOUNT_NAME: Final = "account_name"
|
||||
CONF_CONTAINER_NAME: Final = "container_name"
|
||||
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
)
|
||||
12
homeassistant/components/azure_storage/manifest.json
Normal file
12
homeassistant/components/azure_storage/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "azure_storage",
|
||||
"name": "Azure Storage",
|
||||
"codeowners": ["@zweckj"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/azure_storage",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["azure-storage-blob"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["azure-storage-blob==12.24.0"]
|
||||
}
|
||||
133
homeassistant/components/azure_storage/quality_scale.yaml
Normal file
133
homeassistant/components/azure_storage/quality_scale.yaml
Normal file
@@ -0,0 +1,133 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any configuration parameters.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have platforms.
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single service.
|
||||
diagnostics:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is no data to diagnose.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service and does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service and does not support discovery.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not poll or push.
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration only serves backup.
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service.
|
||||
docs-supported-functions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single service.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: done
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single service.
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
48
homeassistant/components/azure_storage/strings.json
Normal file
48
homeassistant/components/azure_storage/strings.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"storage_account_key": "Storage account key",
|
||||
"account_name": "Account name",
|
||||
"container_name": "Container name"
|
||||
},
|
||||
"data_description": {
|
||||
"storage_account_key": "Storage account access key used for authorization",
|
||||
"account_name": "Name of the storage account",
|
||||
"container_name": "Name of the storage container to be used (will be created if it does not exist)"
|
||||
},
|
||||
"description": "Set up an Azure (Blob) storage account to be used for backups.",
|
||||
"title": "Add Azure storage account"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"container_not_found": {
|
||||
"title": "Storage container not found",
|
||||
"description": "The storage container {container_name} has not been found in the storage account. Please re-create it manually, then fix this issue."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"account_not_found": {
|
||||
"message": "Storage account {account_name} not found"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Can not connect to storage account {account_name}"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Authentication failed for storage account {account_name}"
|
||||
},
|
||||
"container_not_found": {
|
||||
"message": "Storage container {container_name} not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"""The Backup integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.backup import DATA_BACKUP
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -32,6 +32,7 @@ from .manager import (
|
||||
IdleEvent,
|
||||
IncorrectPasswordError,
|
||||
ManagerBackup,
|
||||
ManagerStateEvent,
|
||||
NewBackup,
|
||||
RestoreBackupEvent,
|
||||
RestoreBackupStage,
|
||||
@@ -63,12 +64,12 @@ __all__ = [
|
||||
"IncorrectPasswordError",
|
||||
"LocalBackupAgent",
|
||||
"ManagerBackup",
|
||||
"ManagerStateEvent",
|
||||
"NewBackup",
|
||||
"RestoreBackupEvent",
|
||||
"RestoreBackupStage",
|
||||
"RestoreBackupState",
|
||||
"WrittenBackup",
|
||||
"async_get_manager",
|
||||
"suggested_filename",
|
||||
"suggested_filename_from_name_date",
|
||||
]
|
||||
@@ -91,7 +92,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
backup_manager = BackupManager(hass, reader_writer)
|
||||
hass.data[DATA_MANAGER] = backup_manager
|
||||
await backup_manager.async_setup()
|
||||
try:
|
||||
await backup_manager.async_setup()
|
||||
except Exception as err:
|
||||
hass.data[DATA_BACKUP].manager_ready.set_exception(err)
|
||||
raise
|
||||
else:
|
||||
hass.data[DATA_BACKUP].manager_ready.set_result(None)
|
||||
|
||||
async_register_websocket_handlers(hass, with_hassio)
|
||||
|
||||
@@ -122,15 +129,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async_register_http_views(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_manager(hass: HomeAssistant) -> BackupManager:
|
||||
"""Get the backup manager instance.
|
||||
|
||||
Raises HomeAssistantError if the backup integration is not available.
|
||||
"""
|
||||
if DATA_MANAGER not in hass.data:
|
||||
raise HomeAssistantError("Backup integration is not available")
|
||||
|
||||
return hass.data[DATA_MANAGER]
|
||||
|
||||
38
homeassistant/components/backup/basic_websocket.py
Normal file
38
homeassistant/components/backup/basic_websocket.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Websocket commands for the Backup integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.backup import async_subscribe_events
|
||||
|
||||
from .const import DATA_MANAGER
|
||||
from .manager import ManagerStateEvent
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_websocket_handlers(hass: HomeAssistant) -> None:
|
||||
"""Register websocket commands."""
|
||||
websocket_api.async_register_command(hass, handle_subscribe_events)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
|
||||
@websocket_api.async_response
|
||||
async def handle_subscribe_events(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to backup events."""
|
||||
|
||||
def on_event(event: ManagerStateEvent) -> None:
|
||||
connection.send_message(websocket_api.event_message(msg["id"], event))
|
||||
|
||||
if DATA_MANAGER in hass.data:
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
on_event(manager.last_event)
|
||||
connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event)
|
||||
connection.send_result(msg["id"])
|
||||
@@ -12,16 +12,19 @@ from typing import TYPE_CHECKING, Self, TypedDict
|
||||
from cronsim import CronSim
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.event import async_call_later, async_track_point_in_time
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import LOGGER
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .models import BackupManagerError, Folder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manager import BackupManager, ManagerBackup
|
||||
|
||||
AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID = "automatic_backup_agents_unavailable"
|
||||
|
||||
CRON_PATTERN_DAILY = "{m} {h} * * *"
|
||||
CRON_PATTERN_WEEKLY = "{m} {h} * * {d}"
|
||||
|
||||
@@ -39,6 +42,7 @@ class StoredBackupConfig(TypedDict):
|
||||
"""Represent the stored backup config."""
|
||||
|
||||
agents: dict[str, StoredAgentConfig]
|
||||
automatic_backups_configured: bool
|
||||
create_backup: StoredCreateBackupConfig
|
||||
last_attempted_automatic_backup: str | None
|
||||
last_completed_automatic_backup: str | None
|
||||
@@ -51,6 +55,7 @@ class BackupConfigData:
|
||||
"""Represent loaded backup config data."""
|
||||
|
||||
agents: dict[str, AgentConfig]
|
||||
automatic_backups_configured: bool # only used by frontend
|
||||
create_backup: CreateBackupConfig
|
||||
last_attempted_automatic_backup: datetime | None = None
|
||||
last_completed_automatic_backup: datetime | None = None
|
||||
@@ -88,6 +93,7 @@ class BackupConfigData:
|
||||
agent_id: AgentConfig(protected=agent_data["protected"])
|
||||
for agent_id, agent_data in data["agents"].items()
|
||||
},
|
||||
automatic_backups_configured=data["automatic_backups_configured"],
|
||||
create_backup=CreateBackupConfig(
|
||||
agent_ids=data["create_backup"]["agent_ids"],
|
||||
include_addons=data["create_backup"]["include_addons"],
|
||||
@@ -127,6 +133,7 @@ class BackupConfigData:
|
||||
agents={
|
||||
agent_id: agent.to_dict() for agent_id, agent in self.agents.items()
|
||||
},
|
||||
automatic_backups_configured=self.automatic_backups_configured,
|
||||
create_backup=self.create_backup.to_dict(),
|
||||
last_attempted_automatic_backup=last_attempted,
|
||||
last_completed_automatic_backup=last_completed,
|
||||
@@ -142,10 +149,12 @@ class BackupConfig:
|
||||
"""Initialize backup config."""
|
||||
self.data = BackupConfigData(
|
||||
agents={},
|
||||
automatic_backups_configured=False,
|
||||
create_backup=CreateBackupConfig(),
|
||||
retention=RetentionConfig(),
|
||||
schedule=BackupSchedule(),
|
||||
)
|
||||
self._hass = hass
|
||||
self._manager = manager
|
||||
|
||||
def load(self, stored_config: StoredBackupConfig) -> None:
|
||||
@@ -159,6 +168,7 @@ class BackupConfig:
|
||||
self,
|
||||
*,
|
||||
agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,
|
||||
automatic_backups_configured: bool | UndefinedType = UNDEFINED,
|
||||
create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
|
||||
retention: RetentionParametersDict | UndefinedType = UNDEFINED,
|
||||
schedule: ScheduleParametersDict | UndefinedType = UNDEFINED,
|
||||
@@ -172,8 +182,12 @@ class BackupConfig:
|
||||
self.data.agents[agent_id] = replace(
|
||||
self.data.agents[agent_id], **agent_config
|
||||
)
|
||||
if automatic_backups_configured is not UNDEFINED:
|
||||
self.data.automatic_backups_configured = automatic_backups_configured
|
||||
if create_backup is not UNDEFINED:
|
||||
self.data.create_backup = replace(self.data.create_backup, **create_backup)
|
||||
if "agent_ids" in create_backup:
|
||||
check_unavailable_agents(self._hass, self._manager)
|
||||
if retention is not UNDEFINED:
|
||||
new_retention = RetentionConfig(**retention)
|
||||
if new_retention != self.data.retention:
|
||||
@@ -554,3 +568,46 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N
|
||||
await manager.async_delete_filtered_backups(
|
||||
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def check_unavailable_agents(hass: HomeAssistant, manager: BackupManager) -> None:
|
||||
"""Check for unavailable agents."""
|
||||
if missing_agent_ids := set(manager.config.data.create_backup.agent_ids) - set(
|
||||
manager.backup_agents
|
||||
):
|
||||
LOGGER.debug(
|
||||
"Agents %s are configured for automatic backup but are unavailable",
|
||||
missing_agent_ids,
|
||||
)
|
||||
|
||||
# Remove issues for unavailable agents that are not unavailable anymore.
|
||||
issue_registry = ir.async_get(hass)
|
||||
existing_missing_agent_issue_ids = {
|
||||
issue_id
|
||||
for domain, issue_id in issue_registry.issues
|
||||
if domain == DOMAIN
|
||||
and issue_id.startswith(AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID)
|
||||
}
|
||||
current_missing_agent_issue_ids = {
|
||||
f"{AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID}_{agent_id}": agent_id
|
||||
for agent_id in missing_agent_ids
|
||||
}
|
||||
for issue_id in existing_missing_agent_issue_ids - set(
|
||||
current_missing_agent_issue_ids
|
||||
):
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
for issue_id, agent_id in current_missing_agent_issue_ids.items():
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
learn_more_url="homeassistant://config/backup",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="automatic_backup_agents_unavailable",
|
||||
translation_placeholders={
|
||||
"agent_id": agent_id,
|
||||
"backup_settings": "/config/backup/settings",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -32,7 +32,9 @@ from homeassistant.helpers import (
|
||||
instance_id,
|
||||
integration_platform,
|
||||
issue_registry as ir,
|
||||
start,
|
||||
)
|
||||
from homeassistant.helpers.backup import DATA_BACKUP
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
from homeassistant.util import dt as dt_util, json as json_util
|
||||
|
||||
@@ -46,6 +48,7 @@ from .agent import (
|
||||
from .config import (
|
||||
BackupConfig,
|
||||
CreateBackupParametersDict,
|
||||
check_unavailable_agents,
|
||||
delete_backups_exceeding_configured_count,
|
||||
)
|
||||
from .const import (
|
||||
@@ -332,7 +335,9 @@ class BackupManager:
|
||||
# Latest backup event and backup event subscribers
|
||||
self.last_event: ManagerStateEvent = IdleEvent()
|
||||
self.last_non_idle_event: ManagerStateEvent | None = None
|
||||
self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
|
||||
self._backup_event_subscriptions = hass.data[
|
||||
DATA_BACKUP
|
||||
].backup_event_subscriptions
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the backup manager."""
|
||||
@@ -414,6 +419,13 @@ class BackupManager:
|
||||
}
|
||||
)
|
||||
|
||||
@callback
|
||||
def check_unavailable_agents_after_start(hass: HomeAssistant) -> None:
|
||||
"""Check unavailable agents after start."""
|
||||
check_unavailable_agents(hass, self)
|
||||
|
||||
start.async_at_started(self.hass, check_unavailable_agents_after_start)
|
||||
|
||||
async def _add_platform(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -1279,19 +1291,6 @@ class BackupManager:
|
||||
for subscription in self._backup_event_subscriptions:
|
||||
subscription(event)
|
||||
|
||||
@callback
|
||||
def async_subscribe_events(
|
||||
self,
|
||||
on_event: Callable[[ManagerStateEvent], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe events."""
|
||||
|
||||
def remove_subscription() -> None:
|
||||
self._backup_event_subscriptions.remove(on_event)
|
||||
|
||||
self._backup_event_subscriptions.append(on_event)
|
||||
return remove_subscription
|
||||
|
||||
def _update_issue_backup_failed(self) -> None:
|
||||
"""Update issue registry when a backup fails."""
|
||||
ir.async_create_issue(
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "calculated",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["cronsim==2.6", "securetar==2025.1.4"]
|
||||
"requirements": ["cronsim==2.6", "securetar==2025.2.1"]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
STORE_DELAY_SAVE = 30
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_VERSION_MINOR = 4
|
||||
STORAGE_VERSION_MINOR = 5
|
||||
|
||||
|
||||
class StoredBackupData(TypedDict):
|
||||
@@ -67,6 +67,11 @@ class _BackupStore(Store[StoredBackupData]):
|
||||
data["config"]["retention"]["copies"] = None
|
||||
if data["config"]["retention"]["days"] == 0:
|
||||
data["config"]["retention"]["days"] = None
|
||||
if old_minor_version < 5:
|
||||
# Version 1.5 adds automatic_backups_configured
|
||||
data["config"]["automatic_backups_configured"] = (
|
||||
data["config"]["create_backup"]["password"] is not None
|
||||
)
|
||||
|
||||
# Note: We allow reading data with major version 2.
|
||||
# Reject if major version is higher than 2.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"issues": {
|
||||
"automatic_backup_agents_unavailable": {
|
||||
"title": "The backup location {agent_id} is unavailable",
|
||||
"description": "The backup location `{agent_id}` is unavailable but is still configured for automatic backups.\n\nPlease visit the [automatic backup configuration page]({backup_settings}) to review and update your backup locations. Backups will not be uploaded to selected locations that are unavailable."
|
||||
},
|
||||
"automatic_backup_failed_create": {
|
||||
"title": "Automatic backup could not be created",
|
||||
"description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
|
||||
@@ -10,11 +10,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .config import Day, ScheduleRecurrence
|
||||
from .const import DATA_MANAGER, LOGGER
|
||||
from .manager import (
|
||||
DecryptOnDowloadNotSupported,
|
||||
IncorrectPasswordError,
|
||||
ManagerStateEvent,
|
||||
)
|
||||
from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError
|
||||
from .models import BackupNotFound, Folder
|
||||
|
||||
|
||||
@@ -34,7 +30,6 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
|
||||
websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
|
||||
websocket_api.async_register_command(hass, handle_delete)
|
||||
websocket_api.async_register_command(hass, handle_restore)
|
||||
websocket_api.async_register_command(hass, handle_subscribe_events)
|
||||
|
||||
websocket_api.async_register_command(hass, handle_config_info)
|
||||
websocket_api.async_register_command(hass, handle_config_update)
|
||||
@@ -352,6 +347,7 @@ async def handle_config_info(
|
||||
{
|
||||
vol.Required("type"): "backup/config/update",
|
||||
vol.Optional("agents"): vol.Schema({str: {"protected": bool}}),
|
||||
vol.Optional("automatic_backups_configured"): bool,
|
||||
vol.Optional("create_backup"): vol.Schema(
|
||||
{
|
||||
vol.Optional("agent_ids"): vol.All([str], vol.Unique()),
|
||||
@@ -400,22 +396,3 @@ def handle_config_update(
|
||||
changes.pop("type")
|
||||
manager.config.update(**changes)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
|
||||
@websocket_api.async_response
|
||||
async def handle_subscribe_events(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to backup events."""
|
||||
|
||||
def on_event(event: ManagerStateEvent) -> None:
|
||||
connection.send_message(websocket_api.event_message(msg["id"], event))
|
||||
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
on_event(manager.last_event)
|
||||
connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"name": "Activity",
|
||||
"state": {
|
||||
"available": "Available",
|
||||
"charging": "Charging",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"unavailable": "Unavailable",
|
||||
"error": "Error",
|
||||
"offline": "Offline"
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"name": "Charging status",
|
||||
"state": {
|
||||
"default": "Default",
|
||||
"charging": "Charging",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"error": "Error",
|
||||
"complete": "Complete",
|
||||
"fully_charged": "Fully charged",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Burbank Water and Power (BWP)."""
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "burbank_water_and_power",
|
||||
"name": "Burbank Water and Power (BWP)",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "opower"
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiostreammagic"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiostreammagic==2.10.0"],
|
||||
"requirements": ["aiostreammagic==2.11.0"],
|
||||
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.1.1",
|
||||
"aiodiscover==2.6.1",
|
||||
"cached-ipaddress==0.8.1"
|
||||
"cached-ipaddress==0.9.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -360,9 +360,9 @@
|
||||
"acb_battery_state": {
|
||||
"name": "Battery state",
|
||||
"state": {
|
||||
"discharging": "Discharging",
|
||||
"discharging": "[%key:common::state::discharging%]",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"charging": "Charging",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"full": "Full"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -141,11 +141,6 @@ class EzvizCamera(EzvizEntity, Camera):
|
||||
if camera_password:
|
||||
self._attr_supported_features = CameraEntityFeature.STREAM
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self.data["status"] != 2
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if on."""
|
||||
|
||||
@@ -42,6 +42,11 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity):
|
||||
"""Return coordinator data for this entity."""
|
||||
return self.coordinator.data[self._serial]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self.data["status"] != 2
|
||||
|
||||
|
||||
class EzvizBaseEntity(Entity):
|
||||
"""Generic entity for EZVIZ individual poll entities."""
|
||||
@@ -72,3 +77,8 @@ class EzvizBaseEntity(Entity):
|
||||
def data(self) -> dict[str, Any]:
|
||||
"""Return coordinator data for this entity."""
|
||||
return self.coordinator.data[self._serial]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self.data["status"] != 2
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from propcache.api import cached_property
|
||||
from pyezviz.exceptions import PyEzvizError
|
||||
from pyezviz.utils import decrypt_image
|
||||
|
||||
@@ -62,6 +63,11 @@ class EzvizLastMotion(EzvizEntity, ImageEntity):
|
||||
else None
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def available(self) -> bool:
|
||||
"""Entity gets data from ezviz API so always available."""
|
||||
return True
|
||||
|
||||
async def _async_load_image_from_url(self, url: str) -> Image | None:
|
||||
"""Load an image by url."""
|
||||
if response := await self._fetch_url(url):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"domain": "frontend",
|
||||
"name": "Home Assistant Frontend",
|
||||
"after_dependencies": ["backup"],
|
||||
"codeowners": ["@home-assistant/frontend"],
|
||||
"dependencies": [
|
||||
"api",
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections.abc import Callable
|
||||
from google_drive_api.exceptions import GoogleDriveApiError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import instance_id
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -49,7 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry)
|
||||
except GoogleDriveApiError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
_async_notify_backup_listeners_soon(hass)
|
||||
def async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
|
||||
|
||||
return True
|
||||
|
||||
@@ -58,15 +62,4 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: GoogleDriveConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
_async_notify_backup_listeners_soon(hass)
|
||||
return True
|
||||
|
||||
|
||||
def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
|
||||
@callback
|
||||
def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
|
||||
hass.loop.call_soon(_async_notify_backup_listeners, hass)
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from google import genai # type: ignore[attr-defined]
|
||||
from google.genai.errors import APIError, ClientError
|
||||
from PIL import Image
|
||||
from requests.exceptions import Timeout
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -26,6 +24,7 @@ from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
@@ -38,6 +37,7 @@ from .const import (
|
||||
|
||||
SERVICE_GENERATE_CONTENT = "generate_content"
|
||||
CONF_IMAGE_FILENAME = "image_filename"
|
||||
CONF_FILENAMES = "filenames"
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = (Platform.CONVERSATION,)
|
||||
@@ -50,31 +50,43 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def generate_content(call: ServiceCall) -> ServiceResponse:
|
||||
"""Generate content from text and optionally images."""
|
||||
|
||||
if call.data[CONF_IMAGE_FILENAME]:
|
||||
# Deprecated in 2025.3, to remove in 2025.9
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_image_filename_parameter",
|
||||
breaks_in_ha_version="2025.9.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_image_filename_parameter",
|
||||
)
|
||||
|
||||
prompt_parts = [call.data[CONF_PROMPT]]
|
||||
|
||||
def append_images_to_prompt():
|
||||
image_filenames = call.data[CONF_IMAGE_FILENAME]
|
||||
for image_filename in image_filenames:
|
||||
if not hass.config.is_allowed_path(image_filename):
|
||||
raise HomeAssistantError(
|
||||
f"Cannot read `{image_filename}`, no access to path; "
|
||||
"`allowlist_external_dirs` may need to be adjusted in "
|
||||
"`configuration.yaml`"
|
||||
)
|
||||
if not Path(image_filename).exists():
|
||||
raise HomeAssistantError(f"`{image_filename}` does not exist")
|
||||
mime_type, _ = mimetypes.guess_type(image_filename)
|
||||
if mime_type is None or not mime_type.startswith("image"):
|
||||
raise HomeAssistantError(f"`{image_filename}` is not an image")
|
||||
prompt_parts.append(Image.open(image_filename))
|
||||
|
||||
await hass.async_add_executor_job(append_images_to_prompt)
|
||||
|
||||
config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries(
|
||||
DOMAIN
|
||||
)[0]
|
||||
|
||||
client = config_entry.runtime_data
|
||||
|
||||
def append_files_to_prompt():
|
||||
image_filenames = call.data[CONF_IMAGE_FILENAME]
|
||||
filenames = call.data[CONF_FILENAMES]
|
||||
for filename in set(image_filenames + filenames):
|
||||
if not hass.config.is_allowed_path(filename):
|
||||
raise HomeAssistantError(
|
||||
f"Cannot read `{filename}`, no access to path; "
|
||||
"`allowlist_external_dirs` may need to be adjusted in "
|
||||
"`configuration.yaml`"
|
||||
)
|
||||
if not Path(filename).exists():
|
||||
raise HomeAssistantError(f"`{filename}` does not exist")
|
||||
prompt_parts.append(client.files.upload(file=filename))
|
||||
|
||||
await hass.async_add_executor_job(append_files_to_prompt)
|
||||
|
||||
try:
|
||||
response = await client.aio.models.generate_content(
|
||||
model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts
|
||||
@@ -105,6 +117,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
vol.Optional(CONF_IMAGE_FILENAME, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
vol.Optional(CONF_FILENAMES, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
}
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
|
||||
@@ -9,3 +9,8 @@ generate_content:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
filenames:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
|
||||
@@ -56,10 +56,21 @@
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "Image filename",
|
||||
"description": "Images",
|
||||
"description": "Deprecated. Use filenames instead.",
|
||||
"example": "/config/www/image.jpg"
|
||||
},
|
||||
"filenames": {
|
||||
"name": "Attachment filenames",
|
||||
"description": "Attachments to add to the prompt (images, PDFs, etc)",
|
||||
"example": "/config/www/image.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_image_filename_parameter": {
|
||||
"title": "Deprecated 'image_filename' parameter",
|
||||
"description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' intead."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
|
||||
"""Set light color in kelvin."""
|
||||
await device.set_temperature(temperature)
|
||||
|
||||
async def set_scene(self, device: GoveeController, scene: str) -> None:
|
||||
"""Set light scene."""
|
||||
await device.set_scene(scene)
|
||||
|
||||
@property
|
||||
def devices(self) -> list[GoveeDevice]:
|
||||
"""Return a list of discovered Govee devices."""
|
||||
|
||||
@@ -10,9 +10,11 @@ from govee_local_api import GoveeDevice, GoveeLightFeatures
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_EFFECT,
|
||||
ATTR_RGB_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
LightEntityFeature,
|
||||
filter_supported_color_modes,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -25,6 +27,8 @@ from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_NONE_SCENE = "none"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -50,10 +54,22 @@ async def async_setup_entry(
|
||||
class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
|
||||
"""Govee Light."""
|
||||
|
||||
_attr_translation_key = "govee_light"
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supported_color_modes: set[ColorMode]
|
||||
_fixed_color_mode: ColorMode | None = None
|
||||
_attr_effect_list: list[str] | None = None
|
||||
_attr_effect: str | None = None
|
||||
_attr_supported_features: LightEntityFeature = LightEntityFeature(0)
|
||||
_last_color_state: (
|
||||
tuple[
|
||||
ColorMode | str | None,
|
||||
int | None,
|
||||
tuple[int, int, int] | tuple[int | None] | None,
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -80,6 +96,13 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
|
||||
if GoveeLightFeatures.BRIGHTNESS & capabilities.features:
|
||||
color_modes.add(ColorMode.BRIGHTNESS)
|
||||
|
||||
if (
|
||||
GoveeLightFeatures.SCENES & capabilities.features
|
||||
and capabilities.scenes
|
||||
):
|
||||
self._attr_supported_features = LightEntityFeature.EFFECT
|
||||
self._attr_effect_list = [_NONE_SCENE, *capabilities.scenes.keys()]
|
||||
|
||||
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
|
||||
if len(self._attr_supported_color_modes) == 1:
|
||||
# If the light supports only a single color mode, set it now
|
||||
@@ -143,12 +166,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
|
||||
|
||||
if ATTR_RGB_COLOR in kwargs:
|
||||
self._attr_color_mode = ColorMode.RGB
|
||||
self._attr_effect = None
|
||||
self._last_color_state = None
|
||||
red, green, blue = kwargs[ATTR_RGB_COLOR]
|
||||
await self.coordinator.set_rgb_color(self._device, red, green, blue)
|
||||
elif ATTR_COLOR_TEMP_KELVIN in kwargs:
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
self._attr_effect = None
|
||||
self._last_color_state = None
|
||||
temperature: float = kwargs[ATTR_COLOR_TEMP_KELVIN]
|
||||
await self.coordinator.set_temperature(self._device, int(temperature))
|
||||
elif ATTR_EFFECT in kwargs:
|
||||
effect = kwargs[ATTR_EFFECT]
|
||||
if effect and self._attr_effect_list and effect in self._attr_effect_list:
|
||||
if effect == _NONE_SCENE:
|
||||
self._attr_effect = None
|
||||
await self._restore_last_color_state()
|
||||
else:
|
||||
self._attr_effect = effect
|
||||
self._save_last_color_state()
|
||||
await self.coordinator.set_scene(self._device, effect)
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
@@ -159,3 +197,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
|
||||
@callback
|
||||
def _update_callback(self, device: GoveeDevice) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _save_last_color_state(self) -> None:
|
||||
color_mode = self.color_mode
|
||||
self._last_color_state = (
|
||||
color_mode,
|
||||
self.brightness,
|
||||
(self.color_temp_kelvin,)
|
||||
if color_mode == ColorMode.COLOR_TEMP
|
||||
else self.rgb_color,
|
||||
)
|
||||
|
||||
async def _restore_last_color_state(self) -> None:
|
||||
if self._last_color_state:
|
||||
color_mode, brightness, color = self._last_color_state
|
||||
if color:
|
||||
if color_mode == ColorMode.RGB:
|
||||
await self.coordinator.set_rgb_color(self._device, *color)
|
||||
elif color_mode == ColorMode.COLOR_TEMP:
|
||||
await self.coordinator.set_temperature(self._device, *color)
|
||||
if brightness:
|
||||
await self.coordinator.set_brightness(
|
||||
self._device, int((float(brightness) / 255.0) * 100.0)
|
||||
)
|
||||
self._last_color_state = None
|
||||
|
||||
@@ -9,5 +9,29 @@
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"light": {
|
||||
"govee_light": {
|
||||
"state_attributes": {
|
||||
"effect": {
|
||||
"state": {
|
||||
"none": "None",
|
||||
"sunrise": "Sunrise",
|
||||
"sunset": "Sunset",
|
||||
"movie": "Movie",
|
||||
"dating": "Dating",
|
||||
"romantic": "Romantic",
|
||||
"twinkle": "Twinkle",
|
||||
"candlelight": "Candlelight",
|
||||
"snowflake": "Snowflake",
|
||||
"energetic": "Energetic",
|
||||
"breathe": "Breathe",
|
||||
"crossing": "Crossing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ ATTR_TYPE = "type"
|
||||
ATTR_PRIORITY = "priority"
|
||||
ATTR_TAG = "tag"
|
||||
ATTR_KEYWORD = "keyword"
|
||||
ATTR_REMOVE_TAG = "remove_tag"
|
||||
ATTR_ALIAS = "alias"
|
||||
ATTR_PRIORITY = "priority"
|
||||
ATTR_COST = "cost"
|
||||
ATTR_NOTES = "notes"
|
||||
|
||||
SERVICE_CAST_SKILL = "cast_skill"
|
||||
SERVICE_START_QUEST = "start_quest"
|
||||
@@ -50,6 +55,7 @@ SERVICE_SCORE_REWARD = "score_reward"
|
||||
|
||||
SERVICE_TRANSFORMATION = "transformation"
|
||||
|
||||
SERVICE_UPDATE_REWARD = "update_reward"
|
||||
|
||||
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
|
||||
X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"
|
||||
|
||||
@@ -217,6 +217,13 @@
|
||||
"sections": {
|
||||
"filter": "mdi:calendar-filter"
|
||||
}
|
||||
},
|
||||
"update_reward": {
|
||||
"service": "mdi:treasure-chest",
|
||||
"sections": {
|
||||
"tag_options": "mdi:tag",
|
||||
"developer_options": "mdi:test-tube"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
@@ -13,6 +14,7 @@ from habiticalib import (
|
||||
NotAuthorizedError,
|
||||
NotFoundError,
|
||||
Skill,
|
||||
Task,
|
||||
TaskData,
|
||||
TaskPriority,
|
||||
TaskType,
|
||||
@@ -20,6 +22,7 @@ from habiticalib import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.todo import ATTR_RENAME
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_NAME, CONF_NAME
|
||||
from homeassistant.core import (
|
||||
@@ -34,14 +37,18 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
|
||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
|
||||
from .const import (
|
||||
ATTR_ALIAS,
|
||||
ATTR_ARGS,
|
||||
ATTR_CONFIG_ENTRY,
|
||||
ATTR_COST,
|
||||
ATTR_DATA,
|
||||
ATTR_DIRECTION,
|
||||
ATTR_ITEM,
|
||||
ATTR_KEYWORD,
|
||||
ATTR_NOTES,
|
||||
ATTR_PATH,
|
||||
ATTR_PRIORITY,
|
||||
ATTR_REMOVE_TAG,
|
||||
ATTR_SKILL,
|
||||
ATTR_TAG,
|
||||
ATTR_TARGET,
|
||||
@@ -61,6 +68,7 @@ from .const import (
|
||||
SERVICE_SCORE_REWARD,
|
||||
SERVICE_START_QUEST,
|
||||
SERVICE_TRANSFORMATION,
|
||||
SERVICE_UPDATE_REWARD,
|
||||
)
|
||||
from .coordinator import HabiticaConfigEntry
|
||||
|
||||
@@ -104,6 +112,21 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_UPDATE_TASK_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
|
||||
vol.Required(ATTR_TASK): cv.string,
|
||||
vol.Optional(ATTR_RENAME): cv.string,
|
||||
vol.Optional(ATTR_NOTES): cv.string,
|
||||
vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_ALIAS): vol.All(
|
||||
cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$")
|
||||
),
|
||||
vol.Optional(ATTR_COST): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_GET_TASKS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
|
||||
@@ -516,6 +539,130 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
|
||||
return result
|
||||
|
||||
async def update_task(call: ServiceCall) -> ServiceResponse:
|
||||
"""Update task action."""
|
||||
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
|
||||
coordinator = entry.runtime_data
|
||||
await coordinator.async_refresh()
|
||||
|
||||
try:
|
||||
current_task = next(
|
||||
task
|
||||
for task in coordinator.data.tasks
|
||||
if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
|
||||
and task.Type is TaskType.REWARD
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="task_not_found",
|
||||
translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
|
||||
) from e
|
||||
|
||||
task_id = current_task.id
|
||||
if TYPE_CHECKING:
|
||||
assert task_id
|
||||
data = Task()
|
||||
|
||||
if rename := call.data.get(ATTR_RENAME):
|
||||
data["text"] = rename
|
||||
|
||||
if (notes := call.data.get(ATTR_NOTES)) is not None:
|
||||
data["notes"] = notes
|
||||
|
||||
tags = cast(list[str], call.data.get(ATTR_TAG))
|
||||
remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG))
|
||||
|
||||
if tags or remove_tags:
|
||||
update_tags = set(current_task.tags)
|
||||
user_tags = {
|
||||
tag.name.lower(): tag.id
|
||||
for tag in coordinator.data.user.tags
|
||||
if tag.id and tag.name
|
||||
}
|
||||
|
||||
if tags:
|
||||
# Creates new tag if it doesn't exist
|
||||
async def create_tag(tag_name: str) -> UUID:
|
||||
tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id
|
||||
if TYPE_CHECKING:
|
||||
assert tag_id
|
||||
return tag_id
|
||||
|
||||
try:
|
||||
update_tags.update(
|
||||
{
|
||||
user_tags.get(tag_name.lower())
|
||||
or (await create_tag(tag_name))
|
||||
for tag_name in tags
|
||||
}
|
||||
)
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
translation_placeholders={"retry_after": str(e.retry_after)},
|
||||
) from e
|
||||
except HabiticaException as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
translation_placeholders={"reason": str(e.error.message)},
|
||||
) from e
|
||||
except ClientError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
translation_placeholders={"reason": str(e)},
|
||||
) from e
|
||||
|
||||
if remove_tags:
|
||||
update_tags.difference_update(
|
||||
{
|
||||
user_tags[tag_name.lower()]
|
||||
for tag_name in remove_tags
|
||||
if tag_name.lower() in user_tags
|
||||
}
|
||||
)
|
||||
|
||||
data["tags"] = list(update_tags)
|
||||
|
||||
if (alias := call.data.get(ATTR_ALIAS)) is not None:
|
||||
data["alias"] = alias
|
||||
|
||||
if (cost := call.data.get(ATTR_COST)) is not None:
|
||||
data["value"] = cost
|
||||
|
||||
try:
|
||||
response = await coordinator.habitica.update_task(task_id, data)
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
translation_placeholders={"retry_after": str(e.retry_after)},
|
||||
) from e
|
||||
except HabiticaException as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
translation_placeholders={"reason": str(e.error.message)},
|
||||
) from e
|
||||
except ClientError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
translation_placeholders={"reason": str(e)},
|
||||
) from e
|
||||
else:
|
||||
return response.data.to_dict(omit_none=True)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UPDATE_REWARD,
|
||||
update_task,
|
||||
schema=SERVICE_UPDATE_TASK_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_API_CALL,
|
||||
|
||||
@@ -140,3 +140,43 @@ get_tasks:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
update_reward:
|
||||
fields:
|
||||
config_entry: *config_entry
|
||||
task: *task
|
||||
rename:
|
||||
selector:
|
||||
text:
|
||||
notes:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
cost:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
step: 0.01
|
||||
unit_of_measurement: "🪙"
|
||||
mode: box
|
||||
tag_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
tag:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
remove_tag:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
developer_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
alias:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -7,7 +7,23 @@
|
||||
"unit_tasks": "tasks",
|
||||
"unit_health_points": "HP",
|
||||
"unit_mana_points": "MP",
|
||||
"unit_experience_points": "XP"
|
||||
"unit_experience_points": "XP",
|
||||
"config_entry_description": "Select the Habitica account to update a task.",
|
||||
"task_description": "The name (or task ID) of the task you want to update.",
|
||||
"rename_name": "Rename",
|
||||
"rename_description": "The new title for the Habitica task.",
|
||||
"notes_name": "Update notes",
|
||||
"notes_description": "The new notes for the Habitica task.",
|
||||
"tag_name": "Add tags",
|
||||
"tag_description": "Add tags to the Habitica task. If a tag does not already exist, a new one will be created.",
|
||||
"remove_tag_name": "Remove tags",
|
||||
"remove_tag_description": "Remove tags from the Habitica task.",
|
||||
"alias_name": "Task alias",
|
||||
"alias_description": "A task alias can be used instead of the name or task ID. Only dashes, underscores, and alphanumeric characters are supported. The task alias must be unique among all your tasks.",
|
||||
"developer_options_name": "Advanced settings",
|
||||
"developer_options_description": "Additional features available in developer mode.",
|
||||
"tag_options_name": "Tags",
|
||||
"tag_options_description": "Add or remove tags from a task."
|
||||
},
|
||||
"config": {
|
||||
"abort": {
|
||||
@@ -457,6 +473,12 @@
|
||||
},
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed. It looks like your API token has been reset. Please re-authenticate using your new token"
|
||||
},
|
||||
"frequency_not_weekly": {
|
||||
"message": "Unable to update task, weekly repeat settings apply only to weekly recurring dailies."
|
||||
},
|
||||
"frequency_not_monthly": {
|
||||
"message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -651,6 +673,54 @@
|
||||
"description": "Use the optional filters to narrow the returned tasks."
|
||||
}
|
||||
}
|
||||
},
|
||||
"update_reward": {
|
||||
"name": "Update a reward",
|
||||
"description": "Updates a specific reward for the selected Habitica character",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::habitica::common::config_entry_name%]",
|
||||
"description": "Select the Habitica account to update a reward."
|
||||
},
|
||||
"task": {
|
||||
"name": "[%key:component::habitica::common::task_name%]",
|
||||
"description": "[%key:component::habitica::common::task_description%]"
|
||||
},
|
||||
"rename": {
|
||||
"name": "[%key:component::habitica::common::rename_name%]",
|
||||
"description": "[%key:component::habitica::common::rename_description%]"
|
||||
},
|
||||
"notes": {
|
||||
"name": "[%key:component::habitica::common::notes_name%]",
|
||||
"description": "[%key:component::habitica::common::notes_description%]"
|
||||
},
|
||||
"tag": {
|
||||
"name": "[%key:component::habitica::common::tag_name%]",
|
||||
"description": "[%key:component::habitica::common::tag_description%]"
|
||||
},
|
||||
"remove_tag": {
|
||||
"name": "[%key:component::habitica::common::remove_tag_name%]",
|
||||
"description": "[%key:component::habitica::common::remove_tag_description%]"
|
||||
},
|
||||
"alias": {
|
||||
"name": "[%key:component::habitica::common::alias_name%]",
|
||||
"description": "[%key:component::habitica::common::alias_description%]"
|
||||
},
|
||||
"cost": {
|
||||
"name": "Cost",
|
||||
"description": "Update the cost of a reward."
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"tag_options": {
|
||||
"name": "[%key:component::habitica::common::tag_options_name%]",
|
||||
"description": "[%key:component::habitica::common::tag_options_description%]"
|
||||
},
|
||||
"developer_options": {
|
||||
"name": "[%key:component::habitica::common::developer_options_name%]",
|
||||
"description": "[%key:component::habitica::common::developer_options_description%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -117,20 +117,24 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
"""Move an item in the To-do list."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.todo_items
|
||||
tasks_order = (
|
||||
self.coordinator.data.user.tasksOrder.todos
|
||||
if self.entity_description.key is HabiticaTodoList.TODOS
|
||||
else self.coordinator.data.user.tasksOrder.dailys
|
||||
)
|
||||
|
||||
if previous_uid:
|
||||
pos = self.todo_items.index(
|
||||
next(item for item in self.todo_items if item.uid == previous_uid)
|
||||
)
|
||||
if pos < self.todo_items.index(
|
||||
next(item for item in self.todo_items if item.uid == uid)
|
||||
):
|
||||
pos = tasks_order.index(UUID(previous_uid))
|
||||
if pos < tasks_order.index(UUID(uid)):
|
||||
pos += 1
|
||||
|
||||
else:
|
||||
pos = 0
|
||||
|
||||
try:
|
||||
await self.coordinator.habitica.reorder_task(UUID(uid), pos)
|
||||
tasks_order[:] = (
|
||||
await self.coordinator.habitica.reorder_task(UUID(uid), pos)
|
||||
).data
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -144,20 +148,6 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
translation_key=f"move_{self.entity_description.key}_item_failed",
|
||||
translation_placeholders={"pos": str(pos)},
|
||||
) from e
|
||||
else:
|
||||
# move tasks in the coordinator until we have fresh data
|
||||
tasks = self.coordinator.data.tasks
|
||||
new_pos = (
|
||||
tasks.index(
|
||||
next(task for task in tasks if task.id == UUID(previous_uid))
|
||||
)
|
||||
+ 1
|
||||
if previous_uid
|
||||
else 0
|
||||
)
|
||||
old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid)))
|
||||
tasks.insert(new_pos, tasks.pop(old_pos))
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
"""Update a Habitica todo."""
|
||||
@@ -271,7 +261,7 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
|
||||
def todo_items(self) -> list[TodoItem]:
|
||||
"""Return the todo items."""
|
||||
|
||||
return [
|
||||
tasks = [
|
||||
*(
|
||||
TodoItem(
|
||||
uid=str(task.id),
|
||||
@@ -288,6 +278,15 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
|
||||
if task.Type is TaskType.TODO
|
||||
),
|
||||
]
|
||||
return sorted(
|
||||
tasks,
|
||||
key=lambda task: (
|
||||
float("inf")
|
||||
if (uid := UUID(task.uid))
|
||||
not in (tasks_order := self.coordinator.data.user.tasksOrder.todos)
|
||||
else tasks_order.index(uid)
|
||||
),
|
||||
)
|
||||
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Create a Habitica todo."""
|
||||
@@ -348,7 +347,7 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
|
||||
if TYPE_CHECKING:
|
||||
assert self.coordinator.data.user.lastCron
|
||||
|
||||
return [
|
||||
tasks = [
|
||||
*(
|
||||
TodoItem(
|
||||
uid=str(task.id),
|
||||
@@ -365,3 +364,12 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
|
||||
if task.Type is TaskType.DAILY
|
||||
)
|
||||
]
|
||||
return sorted(
|
||||
tasks,
|
||||
key=lambda task: (
|
||||
float("inf")
|
||||
if (uid := UUID(task.uid))
|
||||
not in (tasks_order := self.coordinator.data.user.tasksOrder.dailys)
|
||||
else tasks_order.index(uid)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -45,13 +45,13 @@ from homeassistant.components.backup import (
|
||||
RestoreBackupStage,
|
||||
RestoreBackupState,
|
||||
WrittenBackup,
|
||||
async_get_manager as async_get_backup_manager,
|
||||
suggested_filename as suggested_backup_filename,
|
||||
suggested_filename_from_name_date,
|
||||
)
|
||||
from homeassistant.const import __version__ as HAVERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
@@ -751,7 +751,7 @@ async def backup_addon_before_update(
|
||||
|
||||
async def backup_core_before_update(hass: HomeAssistant) -> None:
|
||||
"""Prepare for updating core."""
|
||||
backup_manager = async_get_backup_manager(hass)
|
||||
backup_manager = await async_get_backup_manager(hass)
|
||||
client = get_supervisor_client(hass)
|
||||
|
||||
try:
|
||||
|
||||
@@ -203,7 +203,13 @@ async def _get_client_and_ha_id(
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError("Device entry not found for device id")
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_entry_not_found",
|
||||
translation_placeholders={
|
||||
"device_id": device_id,
|
||||
},
|
||||
)
|
||||
entry: HomeConnectConfigEntry | None = None
|
||||
for entry_id in device_entry.config_entries:
|
||||
_entry = hass.config_entries.async_get_entry(entry_id)
|
||||
@@ -213,7 +219,11 @@ async def _get_client_and_ha_id(
|
||||
break
|
||||
if entry is None:
|
||||
raise ServiceValidationError(
|
||||
"Home Connect config entry not found for that device id"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_found",
|
||||
translation_placeholders={
|
||||
"device_id": device_id,
|
||||
},
|
||||
)
|
||||
|
||||
ha_id = next(
|
||||
@@ -405,6 +415,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
"""Execute calls to services executing a command."""
|
||||
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_command_actions",
|
||||
breaks_in_ha_version="2025.9.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_command_actions",
|
||||
)
|
||||
|
||||
try:
|
||||
await client.put_command(ha_id, command_key=command_key, value=True)
|
||||
except HomeConnectError as err:
|
||||
@@ -610,6 +631,7 @@ async def async_unload_entry(
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions")
|
||||
async_delete_issue(hass, DOMAIN, "deprecated_command_actions")
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ from .coordinator import (
|
||||
)
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
REFRIGERATION_DOOR_BOOLEAN_MAP = {
|
||||
REFRIGERATION_STATUS_DOOR_CLOSED: False,
|
||||
REFRIGERATION_STATUS_DOOR_OPEN: True,
|
||||
|
||||
@@ -18,6 +18,8 @@ from .coordinator import (
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes Home Connect button entity."""
|
||||
|
||||
@@ -36,6 +36,8 @@ from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeConnectLightEntityDescription(LightEntityDescription):
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"requirements": ["aiohomeconnect==0.12.3"],
|
||||
"requirements": ["aiohomeconnect==0.15.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
UNIT_MAP = {
|
||||
"seconds": UnitOfTime.SECONDS,
|
||||
"ml": UnitOfVolume.MILLILITERS,
|
||||
|
||||
@@ -50,6 +50,8 @@ from .coordinator import (
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = {
|
||||
bsh_key_to_translation_key(option): option
|
||||
for option in (
|
||||
|
||||
@@ -27,6 +27,8 @@ from .const import (
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
EVENT_OPTIONS = ["confirmed", "off", "present"]
|
||||
|
||||
|
||||
@@ -177,6 +179,12 @@ SENSORS = (
|
||||
],
|
||||
translation_key="last_selected_map",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="current_cavity_temperature",
|
||||
),
|
||||
)
|
||||
|
||||
EVENT_SENSORS = (
|
||||
|
||||
@@ -33,6 +33,12 @@
|
||||
"appliance_not_found": {
|
||||
"message": "Appliance for device ID {device_id} not found"
|
||||
},
|
||||
"device_entry_not_found": {
|
||||
"message": "Device entry for device ID {device_id} not found"
|
||||
},
|
||||
"config_entry_not_found": {
|
||||
"message": "Config entry for device ID {device_id} not found"
|
||||
},
|
||||
"turn_on_light": {
|
||||
"message": "Error turning on {entity_id}: {error}"
|
||||
},
|
||||
@@ -108,6 +114,10 @@
|
||||
"title": "Deprecated binary door sensor detected in some automations or scripts",
|
||||
"description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue."
|
||||
},
|
||||
"deprecated_command_actions": {
|
||||
"title": "The command related actions are deprecated in favor of the new buttons",
|
||||
"description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue."
|
||||
},
|
||||
"deprecated_program_switch": {
|
||||
"title": "Deprecated program switch detected in some automations or scripts",
|
||||
"description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue."
|
||||
@@ -1519,6 +1529,9 @@
|
||||
"map3": "Map 3"
|
||||
}
|
||||
},
|
||||
"current_cavity_temperature": {
|
||||
"name": "Current cavity temperature"
|
||||
},
|
||||
"freezer_door_alarm": {
|
||||
"name": "Freezer door alarm",
|
||||
"state": {
|
||||
|
||||
@@ -42,6 +42,7 @@ from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
SWITCHES = (
|
||||
SwitchEntityDescription(
|
||||
|
||||
@@ -23,6 +23,8 @@ from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
TIME_ENTITIES = (
|
||||
TimeEntityDescription(
|
||||
key=SettingKey.BSH_COMMON_ALARM_CLOCK,
|
||||
|
||||
@@ -18,8 +18,10 @@ PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VALVE,
|
||||
]
|
||||
|
||||
type HomeeConfigEntry = ConfigEntry[Homee]
|
||||
|
||||
@@ -75,4 +75,4 @@ class HomeeButton(HomeeEntity, ButtonEntity):
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self.async_set_value(1)
|
||||
await self.async_set_homee_value(1)
|
||||
|
||||
@@ -205,17 +205,17 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
"""Open the cover."""
|
||||
assert self._open_close_attribute is not None
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
await self.async_set_value(self._open_close_attribute, 0)
|
||||
await self.async_set_homee_value(self._open_close_attribute, 0)
|
||||
else:
|
||||
await self.async_set_value(self._open_close_attribute, 1)
|
||||
await self.async_set_homee_value(self._open_close_attribute, 1)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close cover."""
|
||||
assert self._open_close_attribute is not None
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
await self.async_set_value(self._open_close_attribute, 1)
|
||||
await self.async_set_homee_value(self._open_close_attribute, 1)
|
||||
else:
|
||||
await self.async_set_value(self._open_close_attribute, 0)
|
||||
await self.async_set_homee_value(self._open_close_attribute, 0)
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
@@ -230,12 +230,12 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
homee_max = attribute.maximum
|
||||
homee_position = (position / 100) * (homee_max - homee_min) + homee_min
|
||||
|
||||
await self.async_set_value(attribute, homee_position)
|
||||
await self.async_set_homee_value(attribute, homee_position)
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
if self._open_close_attribute is not None:
|
||||
await self.async_set_value(self._open_close_attribute, 2)
|
||||
await self.async_set_homee_value(self._open_close_attribute, 2)
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Open the cover tilt."""
|
||||
@@ -245,9 +245,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
)
|
||||
) is not None:
|
||||
if not slat_attribute.is_reversed:
|
||||
await self.async_set_value(slat_attribute, 2)
|
||||
await self.async_set_homee_value(slat_attribute, 2)
|
||||
else:
|
||||
await self.async_set_value(slat_attribute, 1)
|
||||
await self.async_set_homee_value(slat_attribute, 1)
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Close the cover tilt."""
|
||||
@@ -257,9 +257,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
)
|
||||
) is not None:
|
||||
if not slat_attribute.is_reversed:
|
||||
await self.async_set_value(slat_attribute, 1)
|
||||
await self.async_set_homee_value(slat_attribute, 1)
|
||||
else:
|
||||
await self.async_set_value(slat_attribute, 2)
|
||||
await self.async_set_homee_value(slat_attribute, 2)
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
@@ -276,4 +276,4 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
homee_max = attribute.maximum
|
||||
homee_position = (position / 100) * (homee_max - homee_min) + homee_min
|
||||
|
||||
await self.async_set_value(attribute, homee_position)
|
||||
await self.async_set_homee_value(attribute, homee_position)
|
||||
|
||||
@@ -54,7 +54,7 @@ class HomeeEntity(Entity):
|
||||
"""Return the availability of the underlying node."""
|
||||
return (self._attribute.state == AttributeState.NORMAL) and self._host_connected
|
||||
|
||||
async def async_set_value(self, value: float) -> None:
|
||||
async def async_set_homee_value(self, value: float) -> None:
|
||||
"""Set an attribute value on the homee node."""
|
||||
homee = self._entry.runtime_data
|
||||
try:
|
||||
@@ -144,7 +144,9 @@ class HomeeNodeEntity(Entity):
|
||||
|
||||
return None
|
||||
|
||||
async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None:
|
||||
async def async_set_homee_value(
|
||||
self, attribute: HomeeAttribute, value: float
|
||||
) -> None:
|
||||
"""Set an attribute value on the homee node."""
|
||||
homee = self._entry.runtime_data
|
||||
try:
|
||||
|
||||
@@ -175,24 +175,26 @@ class HomeeLight(HomeeNodeEntity, LightEntity):
|
||||
kwargs[ATTR_BRIGHTNESS],
|
||||
)
|
||||
)
|
||||
await self.async_set_value(self._dimmer_attr, target_value)
|
||||
await self.async_set_homee_value(self._dimmer_attr, target_value)
|
||||
else:
|
||||
# If no brightness value is given, just turn on.
|
||||
await self.async_set_value(self._on_off_attr, 1)
|
||||
await self.async_set_homee_value(self._on_off_attr, 1)
|
||||
|
||||
if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temp_attr is not None:
|
||||
await self.async_set_value(self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN])
|
||||
await self.async_set_homee_value(
|
||||
self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN]
|
||||
)
|
||||
if ATTR_HS_COLOR in kwargs:
|
||||
color = kwargs[ATTR_HS_COLOR]
|
||||
if self._col_attr is not None:
|
||||
await self.async_set_value(
|
||||
await self.async_set_homee_value(
|
||||
self._col_attr,
|
||||
rgb_list_to_decimal(color_hs_to_RGB(*color)),
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn off."""
|
||||
await self.async_set_value(self._on_off_attr, 0)
|
||||
await self.async_set_homee_value(self._on_off_attr, 0)
|
||||
|
||||
def _get_supported_color_modes(self) -> set[ColorMode]:
|
||||
"""Determine the supported color modes from the available attributes."""
|
||||
|
||||
130
homeassistant/components/homee/number.py
Normal file
130
homeassistant/components/homee/number.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""The Homee number platform."""
|
||||
|
||||
from pyHomee.const import AttributeType
|
||||
from pyHomee.model import HomeeAttribute
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeeConfigEntry
|
||||
from .const import HOMEE_UNIT_TO_HA_UNIT
|
||||
from .entity import HomeeEntity
|
||||
|
||||
NUMBER_DESCRIPTIONS = {
|
||||
AttributeType.DOWN_POSITION: NumberEntityDescription(
|
||||
key="down_position",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.DOWN_SLAT_POSITION: NumberEntityDescription(
|
||||
key="down_slat_position",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.DOWN_TIME: NumberEntityDescription(
|
||||
key="down_time",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.ENDPOSITION_CONFIGURATION: NumberEntityDescription(
|
||||
key="endposition_configuration",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.MOTION_ALARM_CANCELATION_DELAY: NumberEntityDescription(
|
||||
key="motion_alarm_cancelation_delay",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: NumberEntityDescription(
|
||||
key="open_window_detection_sensibility",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.POLLING_INTERVAL: NumberEntityDescription(
|
||||
key="polling_interval",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.SHUTTER_SLAT_TIME: NumberEntityDescription(
|
||||
key="shutter_slat_time",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.SLAT_MAX_ANGLE: NumberEntityDescription(
|
||||
key="slat_max_angle",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.SLAT_MIN_ANGLE: NumberEntityDescription(
|
||||
key="slat_min_angle",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.SLAT_STEPS: NumberEntityDescription(
|
||||
key="slat_steps",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.TEMPERATURE_OFFSET: NumberEntityDescription(
|
||||
key="temperature_offset",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.UP_TIME: NumberEntityDescription(
|
||||
key="up_time",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.WAKE_UP_INTERVAL: NumberEntityDescription(
|
||||
key="wake_up_interval",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomeeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the Homee platform for the number component."""
|
||||
|
||||
async_add_entities(
|
||||
HomeeNumber(attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type])
|
||||
for node in config_entry.runtime_data.nodes
|
||||
for attribute in node.attributes
|
||||
if attribute.type in NUMBER_DESCRIPTIONS and attribute.data != "fixed_value"
|
||||
)
|
||||
|
||||
|
||||
class HomeeNumber(HomeeEntity, NumberEntity):
|
||||
"""Representation of a Homee number."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
attribute: HomeeAttribute,
|
||||
entry: HomeeConfigEntry,
|
||||
description: NumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Homee number entity."""
|
||||
super().__init__(attribute, entry)
|
||||
self.entity_description = description
|
||||
self._attr_translation_key = description.key
|
||||
self._attr_native_unit_of_measurement = HOMEE_UNIT_TO_HA_UNIT[attribute.unit]
|
||||
self._attr_native_min_value = attribute.minimum
|
||||
self._attr_native_max_value = attribute.maximum
|
||||
self._attr_native_step = attribute.step_value
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return the availability of the entity."""
|
||||
return super().available and self._attribute.editable
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
"""Return the native value of the number."""
|
||||
return int(self._attribute.current_value)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the selected value."""
|
||||
await self.async_set_homee_value(value)
|
||||
@@ -66,6 +66,50 @@
|
||||
"name": "Light {instance}"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"down_position": {
|
||||
"name": "Down position"
|
||||
},
|
||||
"down_slat_position": {
|
||||
"name": "Down slat position"
|
||||
},
|
||||
"down_time": {
|
||||
"name": "Down-movement duration"
|
||||
},
|
||||
"endposition_configuration": {
|
||||
"name": "End position"
|
||||
},
|
||||
"motion_alarm_cancelation_delay": {
|
||||
"name": "Motion alarm delay"
|
||||
},
|
||||
"open_window_detection_sensibility": {
|
||||
"name": "Window open sensibility"
|
||||
},
|
||||
"polling_interval": {
|
||||
"name": "Polling interval"
|
||||
},
|
||||
"shutter_slat_time": {
|
||||
"name": "Slat turn duration"
|
||||
},
|
||||
"slat_max_angle": {
|
||||
"name": "Maximum slat angle"
|
||||
},
|
||||
"slat_min_angle": {
|
||||
"name": "Minimum slat angle"
|
||||
},
|
||||
"slat_steps": {
|
||||
"name": "Slat steps"
|
||||
},
|
||||
"temperature_offset": {
|
||||
"name": "Temperature offset"
|
||||
},
|
||||
"up_time": {
|
||||
"name": "Up-movement duration"
|
||||
},
|
||||
"wake_up_interval": {
|
||||
"name": "Wake-up interval"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"brightness_instance": {
|
||||
"name": "Illuminance {instance}"
|
||||
@@ -205,6 +249,11 @@
|
||||
"watchdog": {
|
||||
"name": "Watchdog"
|
||||
}
|
||||
},
|
||||
"valve": {
|
||||
"valve_position": {
|
||||
"name": "Valve position"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -120,8 +120,8 @@ class HomeeSwitch(HomeeEntity, SwitchEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.async_set_value(1)
|
||||
await self.async_set_homee_value(1)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.async_set_value(0)
|
||||
await self.async_set_homee_value(0)
|
||||
|
||||
81
homeassistant/components/homee/valve.py
Normal file
81
homeassistant/components/homee/valve.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""The Homee valve platform."""
|
||||
|
||||
from pyHomee.const import AttributeType
|
||||
from pyHomee.model import HomeeAttribute
|
||||
|
||||
from homeassistant.components.valve import (
|
||||
ValveDeviceClass,
|
||||
ValveEntity,
|
||||
ValveEntityDescription,
|
||||
ValveEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeeConfigEntry
|
||||
from .entity import HomeeEntity
|
||||
|
||||
VALVE_DESCRIPTIONS = {
|
||||
AttributeType.CURRENT_VALVE_POSITION: ValveEntityDescription(
|
||||
key="valve_position",
|
||||
device_class=ValveDeviceClass.WATER,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomeeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the Homee platform for the valve component."""
|
||||
|
||||
async_add_entities(
|
||||
HomeeValve(attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type])
|
||||
for node in config_entry.runtime_data.nodes
|
||||
for attribute in node.attributes
|
||||
if attribute.type in VALVE_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class HomeeValve(HomeeEntity, ValveEntity):
|
||||
"""Representation of a Homee valve."""
|
||||
|
||||
_attr_reports_position = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
attribute: HomeeAttribute,
|
||||
entry: HomeeConfigEntry,
|
||||
description: ValveEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Homee valve entity."""
|
||||
super().__init__(attribute, entry)
|
||||
self.entity_description = description
|
||||
self._attr_translation_key = description.key
|
||||
|
||||
@property
|
||||
def supported_features(self) -> ValveEntityFeature:
|
||||
"""Return the supported features."""
|
||||
if self._attribute.editable:
|
||||
return ValveEntityFeature.SET_POSITION
|
||||
return ValveEntityFeature(0)
|
||||
|
||||
@property
|
||||
def current_valve_position(self) -> int | None:
|
||||
"""Return the current valve position."""
|
||||
return int(self._attribute.current_value)
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool:
|
||||
"""Return if the valve is closing."""
|
||||
return self._attribute.target_value < self._attribute.current_value
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool:
|
||||
"""Return if the valve is opening."""
|
||||
return self._attribute.target_value > self._attribute.current_value
|
||||
|
||||
async def async_set_valve_position(self, position: int) -> None:
|
||||
"""Move the valve to a specific position."""
|
||||
await self.async_set_homee_value(position)
|
||||
@@ -10,7 +10,7 @@
|
||||
"loggers": ["pyhap"],
|
||||
"requirements": [
|
||||
"HAP-python==4.9.2",
|
||||
"fnv-hash-fast==1.2.3",
|
||||
"fnv-hash-fast==1.2.6",
|
||||
"PyQRCode==1.2.1",
|
||||
"base36==0.1.1"
|
||||
],
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"fields": {
|
||||
"cycle": {
|
||||
"name": "[%key:component::input_select::services::select_next::fields::cycle::name%]",
|
||||
"description": "[%key:component::input_select::services::select_next::fields::cycle::description%]"
|
||||
"description": "If the option should cycle from the first to the last option on the list."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -296,7 +296,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
metadata = {
|
||||
"source": RECORDER_DOMAIN,
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issue_1",
|
||||
"statistic_id": "sensor.statistics_issues_issue_1",
|
||||
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
|
||||
"has_mean": True,
|
||||
"has_sum": False,
|
||||
@@ -308,7 +308,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
metadata = {
|
||||
"source": RECORDER_DOMAIN,
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issue_2",
|
||||
"statistic_id": "sensor.statistics_issues_issue_2",
|
||||
"unit_of_measurement": "cats",
|
||||
"has_mean": True,
|
||||
"has_sum": False,
|
||||
@@ -320,7 +320,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
metadata = {
|
||||
"source": RECORDER_DOMAIN,
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issue_3",
|
||||
"statistic_id": "sensor.statistics_issues_issue_3",
|
||||
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
|
||||
"has_mean": True,
|
||||
"has_sum": False,
|
||||
@@ -332,7 +332,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
metadata = {
|
||||
"source": RECORDER_DOMAIN,
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issue_4",
|
||||
"statistic_id": "sensor.statistics_issues_issue_4",
|
||||
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
|
||||
"has_mean": True,
|
||||
"has_sum": False,
|
||||
|
||||
@@ -83,7 +83,7 @@ async def async_setup_entry(
|
||||
]
|
||||
|
||||
if (
|
||||
coordinator.device.model == MachineModel.LINEA_MINI
|
||||
coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
|
||||
and coordinator.device.config.scale
|
||||
):
|
||||
entities.extend(
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==1.4.6"]
|
||||
"requirements": ["pylamarzocco==1.4.7"]
|
||||
}
|
||||
|
||||
@@ -220,7 +220,8 @@ SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
|
||||
config.bbw_settings.doses[key] if config.bbw_settings else None
|
||||
),
|
||||
supported_fn=(
|
||||
lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI
|
||||
lambda coordinator: coordinator.device.model
|
||||
in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
|
||||
and coordinator.device.config.scale is not None
|
||||
),
|
||||
),
|
||||
|
||||
@@ -88,6 +88,7 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
|
||||
MachineModel.GS3_AV,
|
||||
MachineModel.LINEA_MICRA,
|
||||
MachineModel.LINEA_MINI,
|
||||
MachineModel.LINEA_MINI_R,
|
||||
),
|
||||
),
|
||||
LaMarzoccoSelectEntityDescription(
|
||||
@@ -138,7 +139,7 @@ async def async_setup_entry(
|
||||
]
|
||||
|
||||
if (
|
||||
coordinator.device.model == MachineModel.LINEA_MINI
|
||||
coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
|
||||
and coordinator.device.config.scale
|
||||
):
|
||||
entities.extend(
|
||||
|
||||
@@ -80,7 +80,7 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
||||
BoilerType.STEAM
|
||||
].current_temperature,
|
||||
supported_fn=lambda coordinator: coordinator.device.model
|
||||
!= MachineModel.LINEA_MINI,
|
||||
not in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -125,7 +125,8 @@ SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
||||
device.config.scale.battery if device.config.scale else 0
|
||||
),
|
||||
supported_fn=(
|
||||
lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI
|
||||
lambda coordinator: coordinator.device.model
|
||||
in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -148,7 +149,8 @@ async def async_setup_entry(
|
||||
]
|
||||
|
||||
if (
|
||||
config_coordinator.device.model == MachineModel.LINEA_MINI
|
||||
config_coordinator.device.model
|
||||
in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
|
||||
and config_coordinator.device.config.scale
|
||||
):
|
||||
entities.extend(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from collections.abc import Iterable
|
||||
from functools import partial
|
||||
from itertools import chain
|
||||
from typing import cast
|
||||
|
||||
import pypck
|
||||
|
||||
@@ -18,6 +17,11 @@ from homeassistant.const import (
|
||||
CONF_ENTITIES,
|
||||
CONF_SOURCE,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
LIGHT_LUX,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -47,6 +51,17 @@ DEVICE_CLASS_MAPPING = {
|
||||
pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT,
|
||||
}
|
||||
|
||||
UNIT_OF_MEASUREMENT_MAPPING = {
|
||||
pypck.lcn_defs.VarUnit.CELSIUS: UnitOfTemperature.CELSIUS,
|
||||
pypck.lcn_defs.VarUnit.KELVIN: UnitOfTemperature.KELVIN,
|
||||
pypck.lcn_defs.VarUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT,
|
||||
pypck.lcn_defs.VarUnit.LUX_T: LIGHT_LUX,
|
||||
pypck.lcn_defs.VarUnit.LUX_I: LIGHT_LUX,
|
||||
pypck.lcn_defs.VarUnit.METERPERSECOND: UnitOfSpeed.METERS_PER_SECOND,
|
||||
pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT,
|
||||
pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE,
|
||||
}
|
||||
|
||||
|
||||
def add_lcn_entities(
|
||||
config_entry: ConfigEntry,
|
||||
@@ -103,8 +118,10 @@ class LcnVariableSensor(LcnEntity, SensorEntity):
|
||||
config[CONF_DOMAIN_DATA][CONF_UNIT_OF_MEASUREMENT]
|
||||
)
|
||||
|
||||
self._attr_native_unit_of_measurement = cast(str, self.unit.value)
|
||||
self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit, None)
|
||||
self._attr_native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAPPING.get(
|
||||
self.unit
|
||||
)
|
||||
self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"name": "State",
|
||||
"state": {
|
||||
"available": "Available",
|
||||
"charging": "Charging",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"connected": "Connected",
|
||||
"error": "Error",
|
||||
"locked": "Locked",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -10,8 +9,11 @@ from thinqconnect import DeviceType
|
||||
from thinqconnect.integration import ExtendedProperty
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_HVAC_MODE,
|
||||
ATTR_TARGET_TEMP_HIGH,
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
SWING_OFF,
|
||||
SWING_ON,
|
||||
ClimateEntity,
|
||||
ClimateEntityDescription,
|
||||
ClimateEntityFeature,
|
||||
@@ -26,31 +28,19 @@ from . import ThinqConfigEntry
|
||||
from .coordinator import DeviceDataUpdateCoordinator
|
||||
from .entity import ThinQEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ThinQClimateEntityDescription(ClimateEntityDescription):
|
||||
"""Describes ThinQ climate entity."""
|
||||
|
||||
min_temp: float | None = None
|
||||
max_temp: float | None = None
|
||||
step: float | None = None
|
||||
|
||||
|
||||
DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ThinQClimateEntityDescription, ...]] = {
|
||||
DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ClimateEntityDescription, ...]] = {
|
||||
DeviceType.AIR_CONDITIONER: (
|
||||
ThinQClimateEntityDescription(
|
||||
ClimateEntityDescription(
|
||||
key=ExtendedProperty.CLIMATE_AIR_CONDITIONER,
|
||||
name=None,
|
||||
translation_key=ExtendedProperty.CLIMATE_AIR_CONDITIONER,
|
||||
),
|
||||
),
|
||||
DeviceType.SYSTEM_BOILER: (
|
||||
ThinQClimateEntityDescription(
|
||||
ClimateEntityDescription(
|
||||
key=ExtendedProperty.CLIMATE_SYSTEM_BOILER,
|
||||
name=None,
|
||||
min_temp=16,
|
||||
max_temp=30,
|
||||
step=1,
|
||||
translation_key=ExtendedProperty.CLIMATE_SYSTEM_BOILER,
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -63,16 +53,17 @@ STR_TO_HVAC: dict[str, HVACMode] = {
|
||||
"heat": HVACMode.HEAT,
|
||||
}
|
||||
|
||||
HVAC_TO_STR: dict[HVACMode, str] = {
|
||||
HVACMode.AUTO: "auto",
|
||||
HVACMode.COOL: "cool",
|
||||
HVACMode.DRY: "air_dry",
|
||||
HVACMode.FAN_ONLY: "fan",
|
||||
HVACMode.HEAT: "heat",
|
||||
}
|
||||
HVAC_TO_STR = {v: k for k, v in STR_TO_HVAC.items()}
|
||||
|
||||
THINQ_PRESET_MODE: list[str] = ["air_clean", "aroma", "energy_saving"]
|
||||
|
||||
STR_TO_SWING = {
|
||||
"true": SWING_ON,
|
||||
"false": SWING_OFF,
|
||||
}
|
||||
|
||||
SWING_TO_STR = {v: k for k, v in STR_TO_SWING.items()}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -102,12 +93,10 @@ async def async_setup_entry(
|
||||
class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
"""Represent a thinq climate platform."""
|
||||
|
||||
entity_description: ThinQClimateEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DeviceDataUpdateCoordinator,
|
||||
entity_description: ThinQClimateEntityDescription,
|
||||
entity_description: ClimateEntityDescription,
|
||||
property_id: str,
|
||||
) -> None:
|
||||
"""Initialize a climate entity."""
|
||||
@@ -142,6 +131,14 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
self._attr_supported_features |= (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
)
|
||||
# Supports swing mode.
|
||||
if self.data.swing_modes:
|
||||
self._attr_swing_modes = [SWING_ON, SWING_OFF]
|
||||
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
|
||||
|
||||
if self.data.swing_horizontal_modes:
|
||||
self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF]
|
||||
self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update status itself."""
|
||||
@@ -150,6 +147,13 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
# Update fan, hvac and preset mode.
|
||||
if self.supported_features & ClimateEntityFeature.FAN_MODE:
|
||||
self._attr_fan_mode = self.data.fan_mode
|
||||
if self.supported_features & ClimateEntityFeature.SWING_MODE:
|
||||
self._attr_swing_mode = STR_TO_SWING.get(self.data.swing_mode)
|
||||
if self.supported_features & ClimateEntityFeature.SWING_HORIZONTAL_MODE:
|
||||
self._attr_swing_horizontal_mode = STR_TO_SWING.get(
|
||||
self.data.swing_horizontal_mode
|
||||
)
|
||||
|
||||
if self.data.is_on:
|
||||
hvac_mode = self._requested_hvac_mode or self.data.hvac_mode
|
||||
if hvac_mode in STR_TO_HVAC:
|
||||
@@ -166,18 +170,12 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
self._attr_current_temperature = self.data.current_temp
|
||||
|
||||
# Update min, max and step.
|
||||
if (max_temp := self.entity_description.max_temp) is not None or (
|
||||
max_temp := self.data.max
|
||||
) is not None:
|
||||
self._attr_max_temp = max_temp
|
||||
if (min_temp := self.entity_description.min_temp) is not None or (
|
||||
min_temp := self.data.min
|
||||
) is not None:
|
||||
self._attr_min_temp = min_temp
|
||||
if (step := self.entity_description.step) is not None or (
|
||||
step := self.data.step
|
||||
) is not None:
|
||||
self._attr_target_temperature_step = step
|
||||
if self.data.max is not None:
|
||||
self._attr_max_temp = self.data.max
|
||||
if self.data.min is not None:
|
||||
self._attr_min_temp = self.data.min
|
||||
|
||||
self._attr_target_temperature_step = self.data.step
|
||||
|
||||
# Update target temperatures.
|
||||
self._attr_target_temperature = self.data.target_temp
|
||||
@@ -268,6 +266,34 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
self.coordinator.api.async_set_fan_mode(self.property_id, fan_mode)
|
||||
)
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set new swing mode."""
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_swing_mode: %s",
|
||||
self.coordinator.device_name,
|
||||
self.property_id,
|
||||
swing_mode,
|
||||
)
|
||||
await self.async_call_api(
|
||||
self.coordinator.api.async_set_swing_mode(
|
||||
self.property_id, SWING_TO_STR.get(swing_mode)
|
||||
)
|
||||
)
|
||||
|
||||
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
|
||||
"""Set new swing horizontal mode."""
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_swing_horizontal_mode: %s",
|
||||
self.coordinator.device_name,
|
||||
self.property_id,
|
||||
swing_horizontal_mode,
|
||||
)
|
||||
await self.async_call_api(
|
||||
self.coordinator.api.async_set_swing_horizontal_mode(
|
||||
self.property_id, SWING_TO_STR.get(swing_horizontal_mode)
|
||||
)
|
||||
)
|
||||
|
||||
def _round_by_step(self, temperature: float) -> float:
|
||||
"""Round the value by step."""
|
||||
if (
|
||||
@@ -290,6 +316,10 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
self.property_id,
|
||||
kwargs,
|
||||
)
|
||||
if hvac_mode := kwargs.get(ATTR_HVAC_MODE):
|
||||
await self.async_set_hvac_mode(HVACMode(hvac_mode))
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
return
|
||||
|
||||
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None:
|
||||
if (
|
||||
|
||||
@@ -118,16 +118,7 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] =
|
||||
DeviceType.WASHTOWER_DRYER: WASHER_NUMBERS,
|
||||
DeviceType.WASHTOWER: WASHER_NUMBERS,
|
||||
DeviceType.WASHTOWER_WASHER: WASHER_NUMBERS,
|
||||
DeviceType.WATER_HEATER: (
|
||||
NumberEntityDescription(
|
||||
key=ThinQProperty.TARGET_TEMPERATURE,
|
||||
native_max_value=60,
|
||||
native_min_value=35,
|
||||
native_step=1,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
translation_key=ThinQProperty.TARGET_TEMPERATURE,
|
||||
),
|
||||
),
|
||||
DeviceType.WATER_HEATER: (NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],),
|
||||
DeviceType.WINE_CELLAR: (
|
||||
NUMBER_DESC[ThinQProperty.LIGHT_STATUS],
|
||||
NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],
|
||||
@@ -179,7 +170,7 @@ class ThinQNumberEntity(ThinQEntity, NumberEntity):
|
||||
) is not None:
|
||||
self._attr_native_unit_of_measurement = unit_of_measurement
|
||||
|
||||
# Undate range.
|
||||
# Update range.
|
||||
if (
|
||||
self.entity_description.native_min_value is None
|
||||
and (min_value := self.data.min) is not None
|
||||
|
||||
@@ -411,7 +411,7 @@
|
||||
"cancel": "Cancel",
|
||||
"carbonation": "Carbonation",
|
||||
"change_condition": "Settings Change",
|
||||
"charging": "Charging",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"charging_complete": "Charging completed",
|
||||
"checking_turbidity": "Detecting soil level",
|
||||
"cleaning": "Cleaning",
|
||||
@@ -498,7 +498,7 @@
|
||||
"cancel": "[%key:component::lg_thinq::entity::sensor::current_state::state::cancel%]",
|
||||
"carbonation": "[%key:component::lg_thinq::entity::sensor::current_state::state::carbonation%]",
|
||||
"change_condition": "[%key:component::lg_thinq::entity::sensor::current_state::state::change_condition%]",
|
||||
"charging": "[%key:component::lg_thinq::entity::sensor::current_state::state::charging%]",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"charging_complete": "[%key:component::lg_thinq::entity::sensor::current_state::state::charging_complete%]",
|
||||
"checking_turbidity": "[%key:component::lg_thinq::entity::sensor::current_state::state::checking_turbidity%]",
|
||||
"cleaning": "[%key:component::lg_thinq::entity::sensor::current_state::state::cleaning%]",
|
||||
|
||||
@@ -25,10 +25,12 @@ MODELS_ARYLIC_A30: Final[str] = "A30"
|
||||
MODELS_ARYLIC_A50: Final[str] = "A50"
|
||||
MODELS_ARYLIC_A50S: Final[str] = "A50+"
|
||||
MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0"
|
||||
MODELS_ARYLIC_UP2STREAM_AMP_2P1: Final[str] = "Up2Stream Amp 2.1"
|
||||
MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3"
|
||||
MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4"
|
||||
MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1"
|
||||
MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3"
|
||||
MODELS_ARYLIC_S10P: Final[str] = "Arylic S10+"
|
||||
MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp"
|
||||
MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5"
|
||||
MODELS_WIIM_AMP: Final[str] = "WiiM Amp"
|
||||
@@ -49,9 +51,10 @@ PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = {
|
||||
"UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3),
|
||||
"UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4),
|
||||
"UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3),
|
||||
"S10P_WIFI": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S10P),
|
||||
"ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP),
|
||||
"UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
|
||||
"UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
|
||||
"UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_2P1),
|
||||
"RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
|
||||
"ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
|
||||
"ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"fields": {
|
||||
"name": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"description": "Custom name for an entity, can be referenced using an `entity_id`."
|
||||
"description": "Custom name for an entity, can be referenced using the 'Entity ID' field."
|
||||
},
|
||||
"message": {
|
||||
"name": "Message",
|
||||
|
||||
@@ -9,10 +9,21 @@ from urllib.error import HTTPError
|
||||
from pylutron import Lutron
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
NumberSelectorMode,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -68,3 +79,36 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> OptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
|
||||
class OptionsFlowHandler(OptionsFlow):
|
||||
"""Handle option flow for lutron."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle options flow."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_DEFAULT_DIMMER_LEVEL,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL
|
||||
),
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(min=1, max=255, mode=NumberSelectorMode.SLIDER)
|
||||
)
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="init", data_schema=data_schema)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
"""Lutron constants."""
|
||||
|
||||
DOMAIN = "lutron"
|
||||
|
||||
CONF_DEFAULT_DIMMER_LEVEL = "default_dimmer_level"
|
||||
|
||||
DEFAULT_DIMMER_LEVEL = 255 / 2
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from pylutron import Output
|
||||
from pylutron import Lutron, LutronEntity, Output
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
@@ -20,6 +20,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, LutronData
|
||||
from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL
|
||||
from .entity import LutronDevice
|
||||
|
||||
|
||||
@@ -37,7 +38,7 @@ async def async_setup_entry(
|
||||
|
||||
async_add_entities(
|
||||
(
|
||||
LutronLight(area_name, device, entry_data.client)
|
||||
LutronLight(area_name, device, entry_data.client, config_entry)
|
||||
for area_name, device in entry_data.lights
|
||||
),
|
||||
True,
|
||||
@@ -64,6 +65,17 @@ class LutronLight(LutronDevice, LightEntity):
|
||||
_prev_brightness: int | None = None
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
area_name: str,
|
||||
lutron_device: LutronEntity,
|
||||
controller: Lutron,
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the device."""
|
||||
super().__init__(area_name, lutron_device, controller)
|
||||
self._config_entry = config_entry
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
if flash := kwargs.get(ATTR_FLASH):
|
||||
@@ -72,7 +84,9 @@ class LutronLight(LutronDevice, LightEntity):
|
||||
if ATTR_BRIGHTNESS in kwargs and self._lutron_device.is_dimmable:
|
||||
brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
elif self._prev_brightness == 0:
|
||||
brightness = 255 / 2
|
||||
brightness = self._config_entry.options.get(
|
||||
CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL
|
||||
)
|
||||
else:
|
||||
brightness = self._prev_brightness
|
||||
self._prev_brightness = brightness
|
||||
|
||||
@@ -19,6 +19,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"default_dimmer_level": "Default light level when first turning on a light from Home Assistant"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"event": {
|
||||
"button": {
|
||||
|
||||
@@ -263,7 +263,7 @@
|
||||
"paused": "[%key:common::state::paused%]",
|
||||
"error": "Error",
|
||||
"seeking_charger": "Seeking charger",
|
||||
"charging": "Charging",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"docked": "Docked"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -152,6 +152,14 @@ class AtaDeviceClimate(MelCloudClimate):
|
||||
self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}"
|
||||
self._attr_device_info = self.api.device_info
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# We can only check for vane_horizontal once we fetch the device data from the cloud
|
||||
if self._device.vane_horizontal:
|
||||
self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the optional state attributes with device specific additions."""
|
||||
@@ -274,15 +282,29 @@ class AtaDeviceClimate(MelCloudClimate):
|
||||
"""Return vertical vane position or mode."""
|
||||
return self._device.vane_vertical
|
||||
|
||||
@property
|
||||
def swing_horizontal_mode(self) -> str | None:
|
||||
"""Return horizontal vane position or mode."""
|
||||
return self._device.vane_horizontal
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set vertical vane position or mode."""
|
||||
await self.async_set_vane_vertical(swing_mode)
|
||||
|
||||
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
|
||||
"""Set horizontal vane position or mode."""
|
||||
await self.async_set_vane_horizontal(swing_horizontal_mode)
|
||||
|
||||
@property
|
||||
def swing_modes(self) -> list[str] | None:
|
||||
"""Return a list of available vertical vane positions and modes."""
|
||||
return self._device.vane_vertical_positions
|
||||
|
||||
@property
|
||||
def swing_horizontal_modes(self) -> list[str] | None:
|
||||
"""Return a list of available horizontal vane positions and modes."""
|
||||
return self._device.vane_horizontal_positions
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the entity on."""
|
||||
await self._device.set({"power": True})
|
||||
|
||||
@@ -22,6 +22,9 @@ BINARY_SENSOR_DESCRIPTIONS = [
|
||||
),
|
||||
]
|
||||
|
||||
# Coordinator is used to centralize the data updates.
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -8,10 +8,10 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_TYPE
|
||||
|
||||
from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType
|
||||
from .const import DEFAULT_NAME, DOMAIN
|
||||
from .const import DOMAIN
|
||||
|
||||
DEFAULT_ADDRESS = "localhost:25565"
|
||||
|
||||
@@ -37,7 +37,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# Prepare config entry data.
|
||||
config_data = {
|
||||
CONF_NAME: user_input[CONF_NAME],
|
||||
CONF_ADDRESS: address,
|
||||
}
|
||||
|
||||
@@ -78,9 +77,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME)
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_ADDRESS,
|
||||
default=user_input.get(CONF_ADDRESS, DEFAULT_ADDRESS),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Constants for the Minecraft Server integration."""
|
||||
|
||||
DEFAULT_NAME = "Minecraft Server"
|
||||
|
||||
DOMAIN = "minecraft_server"
|
||||
|
||||
KEY_LATENCY = "latency"
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -42,7 +42,7 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]):
|
||||
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
name=config_entry.data[CONF_NAME],
|
||||
name=config_entry.title,
|
||||
config_entry=config_entry,
|
||||
logger=_LOGGER,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
|
||||
@@ -5,12 +5,12 @@ from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_NAME
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import MinecraftServerConfigEntry
|
||||
|
||||
TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"}
|
||||
TO_REDACT: Iterable[Any] = {CONF_ADDRESS, "players_list"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/minecraft_server",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["dnspython", "mcstatus"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["mcstatus==11.1.1"]
|
||||
}
|
||||
|
||||
@@ -6,22 +6,15 @@ rules:
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: Check removal and replacement of name in config flow with the title (server address).
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
Merge test_show_config_form with full flow test.
|
||||
Move full flow test to the top of all tests.
|
||||
All test cases should end in either CREATE_ENTRY or ABORT.
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration doesn't provide any service actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: todo
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: done
|
||||
comment: Handled by coordinator.
|
||||
@@ -58,11 +51,7 @@ rules:
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: Handled by coordinator.
|
||||
parallel-updates:
|
||||
status: todo
|
||||
comment: |
|
||||
Although this is handled by the coordinator and no service actions are provided,
|
||||
PARALLEL_UPDATES should still be set to 0 in binary_sensor and sensor according to the rule.
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: No authentication is required for the integration.
|
||||
|
||||
@@ -30,6 +30,9 @@ KEY_VERSION = "version"
|
||||
UNIT_PLAYERS_MAX = "players"
|
||||
UNIT_PLAYERS_ONLINE = "players"
|
||||
|
||||
# Coordinator is used to centralize the data updates.
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MinecraftServerSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user