mirror of
https://github.com/home-assistant/core.git
synced 2026-01-11 01:57:16 +01:00
Compare commits
417 Commits
2025.12.0b
...
electrolux
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c058810461 | ||
|
|
0ccfd77fef | ||
|
|
4805b33a27 | ||
|
|
c333036959 | ||
|
|
002eed24f1 | ||
|
|
9a9f8271b3 | ||
|
|
855d7c6e16 | ||
|
|
837de55ce6 | ||
|
|
81ed259c59 | ||
|
|
5f00452c96 | ||
|
|
06a44de3fb | ||
|
|
11b4d75cfb | ||
|
|
845c9ee05f | ||
|
|
dedf6b1223 | ||
|
|
c1b631d049 | ||
|
|
6cc645bc6c | ||
|
|
f10866395d | ||
|
|
df68448b27 | ||
|
|
bf7b96622c | ||
|
|
53c644ac5b | ||
|
|
5e9107e52b | ||
|
|
ca9ea267c7 | ||
|
|
f1bfe2f11e | ||
|
|
34cc6036b9 | ||
|
|
2facfbadaa | ||
|
|
1b1dface35 | ||
|
|
3c0cfd5e0c | ||
|
|
69f66ffef4 | ||
|
|
d2c3543b6c | ||
|
|
ca4a2d441e | ||
|
|
f42fe9cee3 | ||
|
|
b67873f40c | ||
|
|
ecc08fce0f | ||
|
|
375f536b15 | ||
|
|
5cff813eac | ||
|
|
c2ce322af1 | ||
|
|
079f306a65 | ||
|
|
9129665c64 | ||
|
|
7bf60f9d15 | ||
|
|
7dddd89ac2 | ||
|
|
a2322ef3c7 | ||
|
|
5f6ef2109a | ||
|
|
44f0a8899a | ||
|
|
78fa29b41f | ||
|
|
06d4f085c0 | ||
|
|
f4e11da1a6 | ||
|
|
e0238b5ab2 | ||
|
|
352f3813e2 | ||
|
|
b1399a5541 | ||
|
|
316cddec86 | ||
|
|
2f71aec26f | ||
|
|
aa72b76ee7 | ||
|
|
e009898107 | ||
|
|
ceb13e70b9 | ||
|
|
498a80ac7f | ||
|
|
a9deb2a08a | ||
|
|
0d26d22986 | ||
|
|
062366966b | ||
|
|
1a60c46d67 | ||
|
|
62fba5ca20 | ||
|
|
b54cde795c | ||
|
|
0f456373bf | ||
|
|
a5042027b8 | ||
|
|
1b8a50e80a | ||
|
|
59761385f0 | ||
|
|
6536d348e5 | ||
|
|
c157c83d54 | ||
|
|
77425cc40f | ||
|
|
b15b5ba95c | ||
|
|
cd6e72798e | ||
|
|
739157e59f | ||
|
|
267aa1af42 | ||
|
|
7328b61a69 | ||
|
|
c4b67329c3 | ||
|
|
c1f8c89bd0 | ||
|
|
b1bf6f5678 | ||
|
|
d347136188 | ||
|
|
a4319f3bf8 | ||
|
|
db27aee62a | ||
|
|
a7446b3da9 | ||
|
|
7fc5464621 | ||
|
|
a00b50c195 | ||
|
|
203f2fb364 | ||
|
|
b956c17ce4 | ||
|
|
5163dc0567 | ||
|
|
31a0478717 | ||
|
|
24da3f0db8 | ||
|
|
786922fc5d | ||
|
|
c2f8b6986b | ||
|
|
0a0832671f | ||
|
|
7b353d7ad4 | ||
|
|
99de73a729 | ||
|
|
1995fbd252 | ||
|
|
315ea9dc76 | ||
|
|
639a96f8cb | ||
|
|
b6786c5a42 | ||
|
|
6f6e9b8057 | ||
|
|
e0c687e415 | ||
|
|
982362110c | ||
|
|
90dc3a8fdf | ||
|
|
5112742b71 | ||
|
|
8899bc01bd | ||
|
|
738fb59efa | ||
|
|
04e512a48e | ||
|
|
c63aca2d9b | ||
|
|
c95203e095 | ||
|
|
259235ceeb | ||
|
|
c7f1729300 | ||
|
|
065329e668 | ||
|
|
a93ed69fe4 | ||
|
|
189497622d | ||
|
|
ed8f9105ff | ||
|
|
185de98f5e | ||
|
|
e857abb43f | ||
|
|
5b1829f3a1 | ||
|
|
520156a33a | ||
|
|
e3b5342b76 | ||
|
|
951b19e80c | ||
|
|
e2351ecec2 | ||
|
|
d75e5498c6 | ||
|
|
2dd58dbe39 | ||
|
|
4ef17799db | ||
|
|
9373378350 | ||
|
|
18833a194b | ||
|
|
2631c77bee | ||
|
|
c67247bf32 | ||
|
|
18b5ffd365 | ||
|
|
a466fc4a01 | ||
|
|
8a968b5d0e | ||
|
|
3baee5c4ac | ||
|
|
f624a43770 | ||
|
|
242935774b | ||
|
|
051ad5878f | ||
|
|
b2156c1d4c | ||
|
|
7d4394f7ed | ||
|
|
4df172374c | ||
|
|
c97755472e | ||
|
|
ebc9060b01 | ||
|
|
bbcc2a94b3 | ||
|
|
692188fa85 | ||
|
|
2c993ea5a2 | ||
|
|
c4e3a4d65e | ||
|
|
84d2686517 | ||
|
|
ae8980ce5b | ||
|
|
b2d4c9ecb4 | ||
|
|
f5b046ee7d | ||
|
|
55c5fb7374 | ||
|
|
5d78cd328a | ||
|
|
bc36578ada | ||
|
|
e63242e465 | ||
|
|
e84c09745d | ||
|
|
f07991d0ba | ||
|
|
872fef1f6f | ||
|
|
c866dc973c | ||
|
|
e2acf30637 | ||
|
|
29631a2c5a | ||
|
|
1d31e6d0ea | ||
|
|
c765776726 | ||
|
|
723365d8e6 | ||
|
|
3d8e136049 | ||
|
|
2fe9fc7ee3 | ||
|
|
e11e31a1a0 | ||
|
|
989407047d | ||
|
|
6d3087c5a4 | ||
|
|
9bd3c35231 | ||
|
|
b7e97971cf | ||
|
|
4d232c63f8 | ||
|
|
6fc000ee2a | ||
|
|
623d3ecde5 | ||
|
|
0fbb3215b4 | ||
|
|
c82ce1ff89 | ||
|
|
8c891a20e5 | ||
|
|
97c50b2d86 | ||
|
|
ef4062a565 | ||
|
|
e31cce5d9b | ||
|
|
21f6b9a53a | ||
|
|
047e549112 | ||
|
|
4c4aecd9a7 | ||
|
|
733496ff3f | ||
|
|
f682e93243 | ||
|
|
c8fa5b0290 | ||
|
|
8ff2a22664 | ||
|
|
c174ab2d96 | ||
|
|
10f0ff7bd7 | ||
|
|
4a4eb33bf7 | ||
|
|
8199c4e5de | ||
|
|
0bfa8318a7 | ||
|
|
ed66a4920c | ||
|
|
f51007c448 | ||
|
|
bd44402b04 | ||
|
|
99fa92d966 | ||
|
|
1cb8f19020 | ||
|
|
81cdbdd4df | ||
|
|
8109d9a39c | ||
|
|
e1abd451b8 | ||
|
|
2c72cd94f2 | ||
|
|
3bccb4b89c | ||
|
|
6d4fb30630 | ||
|
|
c04411f1bc | ||
|
|
753ea023de | ||
|
|
1ca1cf59eb | ||
|
|
5b01bb1a29 | ||
|
|
15c89d24eb | ||
|
|
b26b2347e6 | ||
|
|
7d54103c09 | ||
|
|
c705a1dc4b | ||
|
|
998bd23446 | ||
|
|
3a1a58d6ad | ||
|
|
f9219dd841 | ||
|
|
402ed7e0f3 | ||
|
|
7a1a5df89e | ||
|
|
df558fc1e7 | ||
|
|
ec66407ef1 | ||
|
|
6b99234a43 | ||
|
|
393be71009 | ||
|
|
12bc1687ec | ||
|
|
c59b322c0a | ||
|
|
e00266463d | ||
|
|
cbc8a33553 | ||
|
|
28582f75d4 | ||
|
|
39cccd212d | ||
|
|
329ea33337 | ||
|
|
521733c420 | ||
|
|
33e9f9a0ff | ||
|
|
5fda2bccbe | ||
|
|
ae75332656 | ||
|
|
b171785f96 | ||
|
|
ff3d6783c6 | ||
|
|
b1e579bea0 | ||
|
|
87241ea051 | ||
|
|
a871ec0bdf | ||
|
|
b8829b645a | ||
|
|
5b056a83d4 | ||
|
|
02a70123c1 | ||
|
|
5f6d2f537a | ||
|
|
5e04e9f04d | ||
|
|
56515ad7b5 | ||
|
|
a1fe2bf4fa | ||
|
|
b8fa8efd91 | ||
|
|
03557b5ef2 | ||
|
|
dafec8ce58 | ||
|
|
6ff3f74347 | ||
|
|
ddd8cf7fde | ||
|
|
1356eea52f | ||
|
|
6188e0e39b | ||
|
|
c82706eaf5 | ||
|
|
07f9bec8b6 | ||
|
|
33d576234b | ||
|
|
9e2b4615f1 | ||
|
|
a46dc7e05f | ||
|
|
7dd9953345 | ||
|
|
1145026190 | ||
|
|
d8f9574bc3 | ||
|
|
e91f8d3a81 | ||
|
|
8c0fd0565e | ||
|
|
cc620fc0f8 | ||
|
|
5a89332680 | ||
|
|
1831c5e249 | ||
|
|
dddd2503ea | ||
|
|
91ba510a1e | ||
|
|
6e5e739496 | ||
|
|
6b39eb069c | ||
|
|
847c332c70 | ||
|
|
1a19f3b527 | ||
|
|
8110935d2d | ||
|
|
af69da94f5 | ||
|
|
c1cf17d4db | ||
|
|
6079637909 | ||
|
|
9268e12b20 | ||
|
|
d07993f4a4 | ||
|
|
441cb4197c | ||
|
|
d2a095588d | ||
|
|
f2578da7db | ||
|
|
22200d6804 | ||
|
|
8a4e5c3a28 | ||
|
|
30f31c7d8c | ||
|
|
232c4255a1 | ||
|
|
236f7cd22c | ||
|
|
5948ff2e31 | ||
|
|
380127bc70 | ||
|
|
b6a1e8251a | ||
|
|
c20236717c | ||
|
|
1fd9feaace | ||
|
|
7ce072b4dc | ||
|
|
45aa0399c7 | ||
|
|
d82b3871c1 | ||
|
|
8f6d1162e5 | ||
|
|
dafce97341 | ||
|
|
ffd5d33bbc | ||
|
|
699fa1617d | ||
|
|
449f0fa5a5 | ||
|
|
2e008d2bb7 | ||
|
|
05dec2619d | ||
|
|
25a6778ba8 | ||
|
|
f564b8cb44 | ||
|
|
ce6bfdebfc | ||
|
|
f00a944ac1 | ||
|
|
3073a99ce6 | ||
|
|
8b04ce1328 | ||
|
|
39f76787ab | ||
|
|
e8acced335 | ||
|
|
758a30eebc | ||
|
|
faf94bea24 | ||
|
|
ff91c57228 | ||
|
|
3d2b506997 | ||
|
|
d3c1c28605 | ||
|
|
d4e1f7741d | ||
|
|
e713632eed | ||
|
|
060ad35ddc | ||
|
|
6c5dba40cd | ||
|
|
a04d595424 | ||
|
|
fe85eaf2a2 | ||
|
|
3551c4b01f | ||
|
|
e7edd51a65 | ||
|
|
0c4f2326ef | ||
|
|
81f4456d7c | ||
|
|
2b608bf15c | ||
|
|
972ed4b27f | ||
|
|
23c167da1b | ||
|
|
34d6938171 | ||
|
|
4bb8590076 | ||
|
|
5e0923b60d | ||
|
|
ad48f3c634 | ||
|
|
2bdd6854eb | ||
|
|
0bf906911c | ||
|
|
874d6f5613 | ||
|
|
43ba10eebd | ||
|
|
64bed19805 | ||
|
|
6357067f0f | ||
|
|
e328ba4045 | ||
|
|
332dbddce6 | ||
|
|
82d935a819 | ||
|
|
4b84998c0c | ||
|
|
e10c1ebcf6 | ||
|
|
0174bad182 | ||
|
|
d5be623684 | ||
|
|
d006b044c8 | ||
|
|
fdd9571623 | ||
|
|
4f4c5152b9 | ||
|
|
b031a082cd | ||
|
|
a1132195fd | ||
|
|
708b3dc8b2 | ||
|
|
8ae0216135 | ||
|
|
1472281cd5 | ||
|
|
ceaa71d198 | ||
|
|
7f0d0c555a | ||
|
|
3b94b2491a | ||
|
|
8c8708d5bc | ||
|
|
ca35102138 | ||
|
|
1a1b50ef1a | ||
|
|
5a4d51e57a | ||
|
|
9e1bc637e2 | ||
|
|
ab879c07ca | ||
|
|
488c97531e | ||
|
|
3b52c5df79 | ||
|
|
7f4b56104d | ||
|
|
ab8135ba1a | ||
|
|
a88599bc09 | ||
|
|
45034279c8 | ||
|
|
9f3dae6254 | ||
|
|
ef36d7b1e5 | ||
|
|
e5346ba017 | ||
|
|
68d41d2a48 | ||
|
|
00a882c20a | ||
|
|
44a6772947 | ||
|
|
f874ba1355 | ||
|
|
4fc125c49a | ||
|
|
8c59196e19 | ||
|
|
326f7f0559 | ||
|
|
11afda8c22 | ||
|
|
f1ee0e4ac9 | ||
|
|
5f522e5afa | ||
|
|
4f6624d0aa | ||
|
|
70990645a7 | ||
|
|
2f7d74ff62 | ||
|
|
885667832b | ||
|
|
4646929987 | ||
|
|
010aea952c | ||
|
|
563678dc47 | ||
|
|
a48f01f213 | ||
|
|
08b758b0d2 | ||
|
|
4306fbea52 | ||
|
|
6f4c479f8f | ||
|
|
1d9c06264e | ||
|
|
d045ecaf13 | ||
|
|
f7c41e694c | ||
|
|
9ee7ed5cdb | ||
|
|
83c4e2abc9 | ||
|
|
a7dbf551a3 | ||
|
|
0b2bb9f6bf | ||
|
|
0769163b67 | ||
|
|
2bb51e1146 | ||
|
|
d2248d282c | ||
|
|
8fe79a88ca | ||
|
|
7a328539b2 | ||
|
|
ec69efee4d | ||
|
|
dbcde549d4 | ||
|
|
988355e138 | ||
|
|
7711eac607 | ||
|
|
32fe53cceb | ||
|
|
3a65d3c0dc | ||
|
|
7fe26223ac | ||
|
|
7e8496afb2 | ||
|
|
2ec5190243 | ||
|
|
a706db8fdb | ||
|
|
a00923c48b | ||
|
|
7480d59f0f | ||
|
|
4c8d9ed401 | ||
|
|
eef10c59db | ||
|
|
a1a1f8dd77 | ||
|
|
c75a5c5151 | ||
|
|
cdaaa2bd8f | ||
|
|
bd84dac8fb | ||
|
|
42cbeca5b0 | ||
|
|
ad0a498d10 | ||
|
|
973405822b | ||
|
|
b883d2f519 |
@@ -27,7 +27,6 @@
|
||||
"charliermarsh.ruff",
|
||||
"ms-python.pylint",
|
||||
"ms-python.vscode-pylance",
|
||||
"visualstudioexptteam.vscodeintellicode",
|
||||
"redhat.vscode-yaml",
|
||||
"esbenp.prettier-vscode",
|
||||
"GitHub.vscode-pull-request-github",
|
||||
|
||||
42
.github/workflows/builder.yml
vendored
42
.github/workflows/builder.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
architectures: ${{ env.ARCHITECTURES }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
@@ -190,7 +190,8 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Cosign
|
||||
- &install_cosign
|
||||
name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
@@ -272,7 +273,7 @@ jobs:
|
||||
- green
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set build additional args
|
||||
run: |
|
||||
@@ -294,7 +295,7 @@ jobs:
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
uses: home-assistant/builder@2025.11.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -310,7 +311,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master
|
||||
@@ -353,10 +354,7 @@ jobs:
|
||||
matrix:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.2.3"
|
||||
- *install_cosign
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -393,7 +391,7 @@ jobs:
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -407,7 +405,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@aa33708b10e362ff993539393ff100fa93ed6a27 # v3.7.1
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -418,9 +416,19 @@ jobs:
|
||||
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Copying ${arch} image to DockerHub..."
|
||||
docker buildx imagetools create \
|
||||
--tag "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx imagetools create \
|
||||
--tag "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"; then
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
|
||||
sleep 10
|
||||
if [ "${attempt}" -eq 3 ]; then
|
||||
echo "Failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
|
||||
done
|
||||
|
||||
@@ -466,7 +474,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
@@ -511,7 +519,7 @@ jobs:
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
|
||||
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
@@ -40,9 +40,9 @@ env:
|
||||
CACHE_VERSION: 2
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.12"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13', '3.14']"
|
||||
HA_SHORT_VERSION: "2026.1"
|
||||
DEFAULT_PYTHON: "3.13.9"
|
||||
ALL_PYTHON_VERSIONS: "['3.13.9', '3.14.0']"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
# 10.6 is the current long-term-support
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Check out code from GitHub
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Generate partial Python venv restore key
|
||||
id: generate_python_cache_key
|
||||
run: |
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -21,14 +21,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@5022b33bc1431add9b2831934daf8147a2ad9331 # v2.0.2
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@5022b33bc1431add9b2831934daf8147a2ad9331 # v2.0.2
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
6
.github/workflows/stale.yml
vendored
6
.github/workflows/stale.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
|
||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: &home-assistant-wheels home-assistant/wheels@6066c17a2a4aafcf7bdfeae01717f63adfcdba98 # 2025.11.0
|
||||
uses: &home-assistant-wheels home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
||||
7
CODEOWNERS
generated
7
CODEOWNERS
generated
@@ -539,6 +539,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/freebox/ @hacf-fr @Quentame
|
||||
/homeassistant/components/freedompro/ @stefano055415
|
||||
/tests/components/freedompro/ @stefano055415
|
||||
/homeassistant/components/fressnapf_tracker/ @eifinger
|
||||
/tests/components/fressnapf_tracker/ @eifinger
|
||||
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
/homeassistant/components/fritzbox/ @mib1185 @flabbamann
|
||||
@@ -569,6 +571,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/generic_hygrostat/ @Shulyaka
|
||||
/homeassistant/components/geniushub/ @manzanotti
|
||||
/tests/components/geniushub/ @manzanotti
|
||||
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/homeassistant/components/geo_json_events/ @exxamalte
|
||||
/tests/components/geo_json_events/ @exxamalte
|
||||
/homeassistant/components/geo_location/ @home-assistant/core
|
||||
@@ -1761,6 +1765,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
/tests/components/vilfo/ @ManneW
|
||||
/homeassistant/components/vivotek/ @HarlemSquirrel
|
||||
/tests/components/vivotek/ @HarlemSquirrel
|
||||
/homeassistant/components/vizio/ @raman325
|
||||
/tests/components/vizio/ @raman325
|
||||
/homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare
|
||||
@@ -1800,6 +1805,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/weatherflow_cloud/ @jeeftor
|
||||
/homeassistant/components/weatherkit/ @tjhorner
|
||||
/tests/components/weatherkit/ @tjhorner
|
||||
/homeassistant/components/web_rtc/ @home-assistant/core
|
||||
/tests/components/web_rtc/ @home-assistant/core
|
||||
/homeassistant/components/webdav/ @jpbede
|
||||
/tests/components/webdav/ @jpbede
|
||||
/homeassistant/components/webhook/ @home-assistant/core
|
||||
|
||||
@@ -35,25 +35,22 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
USER vscode
|
||||
|
||||
COPY .python-version ./
|
||||
RUN uv python install
|
||||
|
||||
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
|
||||
RUN uv venv $VIRTUAL_ENV
|
||||
RUN --mount=type=bind,source=.python-version,target=.python-version \
|
||||
uv python install \
|
||||
&& uv venv $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# Setup hass-release
|
||||
RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \
|
||||
&& uv pip install -e ~/hass-release/
|
||||
|
||||
# Install Python dependencies from requirements
|
||||
COPY requirements.txt ./
|
||||
COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
|
||||
RUN uv pip install -r requirements.txt
|
||||
COPY requirements_test.txt requirements_test_pre_commit.txt ./
|
||||
RUN uv pip install -r requirements_test.txt
|
||||
RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \
|
||||
--mount=type=bind,source=homeassistant/package_constraints.txt,target=homeassistant/package_constraints.txt \
|
||||
--mount=type=bind,source=requirements_test.txt,target=requirements_test.txt \
|
||||
--mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
|
||||
WORKDIR /workspaces
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any, Final
|
||||
from homeassistant.const import (
|
||||
EVENT_COMPONENT_LOADED,
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
EVENT_LABS_UPDATED,
|
||||
EVENT_LOVELACE_UPDATED,
|
||||
EVENT_PANELS_UPDATED,
|
||||
EVENT_RECORDER_5MIN_STATISTICS_GENERATED,
|
||||
@@ -45,6 +46,7 @@ SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = {
|
||||
EVENT_STATE_CHANGED,
|
||||
EVENT_THEMES_UPDATED,
|
||||
EVENT_LABEL_REGISTRY_UPDATED,
|
||||
EVENT_LABS_UPDATED,
|
||||
EVENT_CATEGORY_REGISTRY_UPDATED,
|
||||
EVENT_FLOOR_REGISTRY_UPDATED,
|
||||
}
|
||||
|
||||
@@ -1000,7 +1000,7 @@ class _WatchPendingSetups:
|
||||
# We log every LOG_SLOW_STARTUP_INTERVAL until all integrations are done
|
||||
# once we take over LOG_SLOW_STARTUP_INTERVAL (60s) to start up
|
||||
_LOGGER.warning(
|
||||
"Waiting on integrations to complete setup: %s",
|
||||
"Waiting for integrations to complete setup: %s",
|
||||
self._setup_started,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -174,6 +175,56 @@ class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication dialog."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
# Combine existing data with new password
|
||||
data = {
|
||||
CONF_HOST: reauth_entry.data[CONF_HOST],
|
||||
CONF_USERNAME: reauth_entry.data[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
}
|
||||
|
||||
try:
|
||||
await validate_input(self.hass, data)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"username": reauth_entry.data[CONF_USERNAME],
|
||||
"host": reauth_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
@@ -11,6 +11,7 @@ from pyairobotrest.exceptions import AirobotAuthError, AirobotConnectionError
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -53,7 +54,15 @@ class AirobotDataUpdateCoordinator(DataUpdateCoordinator[AirobotData]):
|
||||
try:
|
||||
status = await self.client.get_statuses()
|
||||
settings = await self.client.get_settings()
|
||||
except (AirobotAuthError, AirobotConnectionError) as err:
|
||||
raise UpdateFailed(f"Failed to communicate with device: {err}") from err
|
||||
except AirobotAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_failed",
|
||||
) from err
|
||||
except AirobotConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_failed",
|
||||
) from err
|
||||
|
||||
return AirobotData(status=status, settings=settings)
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyairobotrest"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyairobotrest==0.1.0"]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -14,15 +15,24 @@
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The thermostat password."
|
||||
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "Airobot thermostat {device_id} discovered at {host}. Enter the password to complete setup. Find the password in the thermostat settings menu under Connectivity → Mobile app."
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "The authentication for Airobot thermostat at {host} (Device ID: {username}) has expired. Please enter the password to reauthenticate. Find the password in the thermostat settings menu under Connectivity → Mobile app."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
"username": "Device ID"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Airobot thermostat.",
|
||||
@@ -34,6 +44,12 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed, please reauthenticate."
|
||||
},
|
||||
"connection_failed": {
|
||||
"message": "Failed to communicate with device."
|
||||
},
|
||||
"set_preset_mode_failed": {
|
||||
"message": "Failed to set preset mode to {preset_mode}."
|
||||
},
|
||||
|
||||
@@ -159,81 +159,74 @@
|
||||
"title": "Alarm control panel",
|
||||
"triggers": {
|
||||
"armed": {
|
||||
"description": "Triggers when an alarm is armed.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed::description%]",
|
||||
"description": "Triggers after one or more alarms become armed, regardless of the mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is armed"
|
||||
"name": "Alarm armed"
|
||||
},
|
||||
"armed_away": {
|
||||
"description": "Triggers when an alarm is armed away.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_away::description%]",
|
||||
"description": "Triggers after one or more alarms become armed in away mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is armed away"
|
||||
"name": "Alarm armed away"
|
||||
},
|
||||
"armed_home": {
|
||||
"description": "Triggers when an alarm is armed home.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_home::description%]",
|
||||
"description": "Triggers after one or more alarms become armed in home mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is armed home"
|
||||
"name": "Alarm armed home"
|
||||
},
|
||||
"armed_night": {
|
||||
"description": "Triggers when an alarm is armed night.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_night::description%]",
|
||||
"description": "Triggers after one or more alarms become armed in night mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is armed night"
|
||||
"name": "Alarm armed night"
|
||||
},
|
||||
"armed_vacation": {
|
||||
"description": "Triggers when an alarm is armed vacation.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_vacation::description%]",
|
||||
"description": "Triggers after one or more alarms become armed in vacation mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is armed vacation"
|
||||
"name": "Alarm armed vacation"
|
||||
},
|
||||
"disarmed": {
|
||||
"description": "Triggers when an alarm is disarmed.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::disarmed::description%]",
|
||||
"description": "Triggers after one or more alarms become disarmed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is disarmed"
|
||||
"name": "Alarm disarmed"
|
||||
},
|
||||
"triggered": {
|
||||
"description": "Triggers when an alarm is triggered.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::triggered::description%]",
|
||||
"description": "Triggers after one or more alarms become triggered.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is triggered"
|
||||
"name": "Alarm triggered"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from pyanglianwater import AnglianWater
|
||||
from pyanglianwater.auth import MSOB2CAuth
|
||||
from pyanglianwater.exceptions import (
|
||||
@@ -18,7 +19,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
||||
from .coordinator import AnglianWaterConfigEntry, AnglianWaterUpdateCoordinator
|
||||
@@ -33,7 +34,10 @@ async def async_setup_entry(
|
||||
auth = MSOB2CAuth(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=async_get_clientsession(hass),
|
||||
session=async_create_clientsession(
|
||||
hass,
|
||||
cookie_jar=CookieJar(quote_cookie=False),
|
||||
),
|
||||
refresh_token=entry.data[CONF_ACCESS_TOKEN],
|
||||
account_number=entry.data[CONF_ACCOUNT_NUMBER],
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
vol.Required(CONF_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(CONF_ACCOUNT_NUMBER): selector.TextSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -68,34 +69,19 @@ class AnglianWaterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.hass,
|
||||
cookie_jar=CookieJar(quote_cookie=False),
|
||||
),
|
||||
account_number=user_input.get(CONF_ACCOUNT_NUMBER),
|
||||
account_number=user_input[CONF_ACCOUNT_NUMBER],
|
||||
)
|
||||
)
|
||||
if isinstance(validation_response, BaseAuth):
|
||||
account_number = (
|
||||
user_input.get(CONF_ACCOUNT_NUMBER)
|
||||
or validation_response.account_number
|
||||
)
|
||||
await self.async_set_unique_id(account_number)
|
||||
await self.async_set_unique_id(user_input[CONF_ACCOUNT_NUMBER])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=account_number,
|
||||
title=user_input[CONF_ACCOUNT_NUMBER],
|
||||
data={
|
||||
**user_input,
|
||||
CONF_ACCESS_TOKEN: validation_response.refresh_token,
|
||||
CONF_ACCOUNT_NUMBER: account_number,
|
||||
},
|
||||
)
|
||||
if validation_response == "smart_meter_unavailable":
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ACCOUNT_NUMBER): selector.TextSelector(),
|
||||
}
|
||||
),
|
||||
errors={"base": validation_response},
|
||||
)
|
||||
errors["base"] = validation_response
|
||||
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -18,17 +18,21 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class AnglianWaterEntity(CoordinatorEntity[AnglianWaterUpdateCoordinator]):
|
||||
"""Defines a Anglian Water entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AnglianWaterUpdateCoordinator,
|
||||
smart_meter: SmartMeter,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize Anglian Water entity."""
|
||||
super().__init__(coordinator)
|
||||
self.smart_meter = smart_meter
|
||||
self._attr_unique_id = f"{smart_meter.serial_number}_{key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, smart_meter.serial_number)},
|
||||
name="Smart Water Meter",
|
||||
name=smart_meter.serial_number,
|
||||
manufacturer="Anglian Water",
|
||||
serial_number=smart_meter.serial_number,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyanglianwater"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyanglianwater==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -108,9 +108,8 @@ class AnglianWaterSensorEntity(AnglianWaterEntity, SensorEntity):
|
||||
description: AnglianWaterSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize Anglian Water sensor."""
|
||||
super().__init__(coordinator, smart_meter)
|
||||
super().__init__(coordinator, smart_meter, description.key)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{smart_meter.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"data_description": {
|
||||
"account_number": "Your account number found on your latest bill.",
|
||||
"password": "Your password",
|
||||
"username": "Username or email used to login to the Anglian Water website."
|
||||
"username": "Username or email used to log in to the Anglian Water website."
|
||||
},
|
||||
"description": "Enter your Anglian Water account credentials to connect to Home Assistant."
|
||||
}
|
||||
|
||||
@@ -421,6 +421,8 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
if model_alias.endswith(("haiku", "opus", "sonnet")):
|
||||
model_alias += "-latest"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
|
||||
@@ -583,7 +583,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Anthropic",
|
||||
model="Claude",
|
||||
model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]),
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.73.0"]
|
||||
"requirements": ["anthropic==0.75.0"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["zeroconf"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyatv", "srptools"],
|
||||
"requirements": ["pyatv==0.16.1;python_version<'3.14'"],
|
||||
|
||||
@@ -1123,63 +1123,6 @@ class PipelineRun:
|
||||
)
|
||||
|
||||
try:
|
||||
user_input = conversation.ConversationInput(
|
||||
text=intent_input,
|
||||
context=self.context,
|
||||
conversation_id=conversation_id,
|
||||
device_id=self._device_id,
|
||||
satellite_id=self._satellite_id,
|
||||
language=input_language,
|
||||
agent_id=self.intent_agent.id,
|
||||
extra_system_prompt=conversation_extra_system_prompt,
|
||||
)
|
||||
|
||||
agent_id = self.intent_agent.id
|
||||
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
|
||||
all_targets_in_satellite_area = False
|
||||
intent_response: intent.IntentResponse | None = None
|
||||
if not processed_locally and not self._intent_agent_only:
|
||||
# Sentence triggers override conversation agent
|
||||
if (
|
||||
trigger_response_text
|
||||
:= await conversation.async_handle_sentence_triggers(
|
||||
self.hass, user_input
|
||||
)
|
||||
) is not None:
|
||||
# Sentence trigger matched
|
||||
agent_id = "sentence_trigger"
|
||||
processed_locally = True
|
||||
intent_response = intent.IntentResponse(
|
||||
self.pipeline.conversation_language
|
||||
)
|
||||
intent_response.async_set_speech(trigger_response_text)
|
||||
|
||||
intent_filter: Callable[[RecognizeResult], bool] | None = None
|
||||
# If the LLM has API access, we filter out some sentences that are
|
||||
# interfering with LLM operation.
|
||||
if (
|
||||
intent_agent_state := self.hass.states.get(self.intent_agent.id)
|
||||
) and intent_agent_state.attributes.get(
|
||||
ATTR_SUPPORTED_FEATURES, 0
|
||||
) & conversation.ConversationEntityFeature.CONTROL:
|
||||
intent_filter = _async_local_fallback_intent_filter
|
||||
|
||||
# Try local intents
|
||||
if (
|
||||
intent_response is None
|
||||
and self.pipeline.prefer_local_intents
|
||||
and (
|
||||
intent_response := await conversation.async_handle_intents(
|
||||
self.hass,
|
||||
user_input,
|
||||
intent_filter=intent_filter,
|
||||
)
|
||||
)
|
||||
):
|
||||
# Local intent matched
|
||||
agent_id = conversation.HOME_ASSISTANT_AGENT
|
||||
processed_locally = True
|
||||
|
||||
if self.tts_stream and self.tts_stream.supports_streaming_input:
|
||||
tts_input_stream: asyncio.Queue[str | None] | None = asyncio.Queue()
|
||||
else:
|
||||
@@ -1265,6 +1208,17 @@ class PipelineRun:
|
||||
assert self.tts_stream is not None
|
||||
self.tts_stream.async_set_message_stream(tts_input_stream_generator())
|
||||
|
||||
user_input = conversation.ConversationInput(
|
||||
text=intent_input,
|
||||
context=self.context,
|
||||
conversation_id=conversation_id,
|
||||
device_id=self._device_id,
|
||||
satellite_id=self._satellite_id,
|
||||
language=input_language,
|
||||
agent_id=self.intent_agent.id,
|
||||
extra_system_prompt=conversation_extra_system_prompt,
|
||||
)
|
||||
|
||||
with (
|
||||
chat_session.async_get_chat_session(
|
||||
self.hass, user_input.conversation_id
|
||||
@@ -1276,6 +1230,53 @@ class PipelineRun:
|
||||
chat_log_delta_listener=chat_log_delta_listener,
|
||||
) as chat_log,
|
||||
):
|
||||
agent_id = self.intent_agent.id
|
||||
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
|
||||
all_targets_in_satellite_area = False
|
||||
intent_response: intent.IntentResponse | None = None
|
||||
if not processed_locally and not self._intent_agent_only:
|
||||
# Sentence triggers override conversation agent
|
||||
if (
|
||||
trigger_response_text
|
||||
:= await conversation.async_handle_sentence_triggers(
|
||||
self.hass, user_input, chat_log
|
||||
)
|
||||
) is not None:
|
||||
# Sentence trigger matched
|
||||
agent_id = "sentence_trigger"
|
||||
processed_locally = True
|
||||
intent_response = intent.IntentResponse(
|
||||
self.pipeline.conversation_language
|
||||
)
|
||||
intent_response.async_set_speech(trigger_response_text)
|
||||
|
||||
intent_filter: Callable[[RecognizeResult], bool] | None = None
|
||||
# If the LLM has API access, we filter out some sentences that are
|
||||
# interfering with LLM operation.
|
||||
if (
|
||||
intent_agent_state := self.hass.states.get(self.intent_agent.id)
|
||||
) and intent_agent_state.attributes.get(
|
||||
ATTR_SUPPORTED_FEATURES, 0
|
||||
) & conversation.ConversationEntityFeature.CONTROL:
|
||||
intent_filter = _async_local_fallback_intent_filter
|
||||
|
||||
# Try local intents
|
||||
if (
|
||||
intent_response is None
|
||||
and self.pipeline.prefer_local_intents
|
||||
and (
|
||||
intent_response := await conversation.async_handle_intents(
|
||||
self.hass,
|
||||
user_input,
|
||||
chat_log,
|
||||
intent_filter=intent_filter,
|
||||
)
|
||||
)
|
||||
):
|
||||
# Local intent matched
|
||||
agent_id = conversation.HOME_ASSISTANT_AGENT
|
||||
processed_locally = True
|
||||
|
||||
# It was already handled, create response and add to chat history
|
||||
if intent_response is not None:
|
||||
speech: str = intent_response.speech.get("plain", {}).get(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.4.0"]
|
||||
"requirements": ["hassil==3.5.0"]
|
||||
}
|
||||
|
||||
@@ -112,48 +112,44 @@
|
||||
"title": "Assist satellite",
|
||||
"triggers": {
|
||||
"idle": {
|
||||
"description": "Triggers when an Assist satellite becomes idle.",
|
||||
"description_configured": "[%key:component::assist_satellite::triggers::idle::description%]",
|
||||
"description": "Triggers after one or more voice assistant satellites become idle after having processed a command.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an Assist satellite becomes idle"
|
||||
"name": "Satellite became idle"
|
||||
},
|
||||
"listening": {
|
||||
"description": "Triggers when an Assist satellite starts listening.",
|
||||
"description_configured": "[%key:component::assist_satellite::triggers::listening::description%]",
|
||||
"description": "Triggers after one or more voice assistant satellites start listening for a command from someone.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an Assist satellite starts listening"
|
||||
"name": "Satellite started listening"
|
||||
},
|
||||
"processing": {
|
||||
"description": "Triggers when an Assist satellite is processing.",
|
||||
"description_configured": "[%key:component::assist_satellite::triggers::processing::description%]",
|
||||
"description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an Assist satellite is processing"
|
||||
"name": "Satellite started processing"
|
||||
},
|
||||
"responding": {
|
||||
"description": "Triggers when an Assist satellite is responding.",
|
||||
"description_configured": "[%key:component::assist_satellite::triggers::responding::description%]",
|
||||
"description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an Assist satellite is responding"
|
||||
"name": "Satellite started responding"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"binary_sensor",
|
||||
"climate",
|
||||
"cover",
|
||||
"fan",
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": {
|
||||
"new_triggers_conditions": {
|
||||
"feedback_url": "https://forms.gle/fWFZqf5MzuwWTsCH8",
|
||||
"learn_more_url": "https://www.home-assistant.io/blog/2025/12/03/release-202512/#purpose-specific-triggers-and-conditions",
|
||||
"report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/automation&integration_name=Automation"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -69,10 +69,10 @@
|
||||
},
|
||||
"preview_features": {
|
||||
"new_triggers_conditions": {
|
||||
"description": "Enables new intuitive triggers and conditions that are more user-friendly than technical state-based options.\n\nThese new automation features support targets across your entire home, letting you trigger automations for any entity, device, area, floor, or label (for example, when any light in your living room turned on). Integrations can now also provide their own intuitive triggers and conditions, just like actions.\n\nThis preview also includes a new tree view to help you navigate your home when adding triggers, conditions, and actions.",
|
||||
"disable_confirmation": "Disabling this preview will cause automations and scripts that use the new intuitive triggers and conditions to fail.\n\nBefore disabling, ensure that your automations or scripts do not rely on this feature.",
|
||||
"enable_confirmation": "This feature is still in development and may change. These new intuitive triggers and conditions are being refined based on user feedback and are not yet complete.\n\nBy enabling this preview, you'll have early access to these new capabilities, but be aware that they may be modified or updated in future releases.",
|
||||
"name": "Intuitive triggers and conditions"
|
||||
"description": "Enables new purpose-specific triggers and conditions that are more user-friendly than technical state-based options.\n\nThese new automation features support targets across your entire home, letting you trigger automations for any entity, device, area, floor, or label (for example, when any light in your living room turned on). Integrations can now also provide their own purpose-specific triggers and conditions, just like actions.\n\nThis preview also includes a new tree view to help you navigate your home when adding triggers, conditions, and actions.",
|
||||
"disable_confirmation": "Disabling this preview will cause automations and scripts that use the new purpose-specific triggers and conditions to fail.\n\nBefore disabling, ensure that your automations or scripts do not rely on this feature.",
|
||||
"enable_confirmation": "This feature is still in development and may change. These new purpose-specific triggers and conditions are being refined based on user feedback and are not yet complete.\n\nBy enabling this preview, you'll have early access to these new capabilities, but be aware that they may be modified or updated in future releases.",
|
||||
"name": "Purpose-specific triggers and conditions"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -21,29 +21,29 @@ from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import DOMAIN
|
||||
from .websocket import BangOlufsenWebsocket
|
||||
from .websocket import BeoWebsocket
|
||||
|
||||
|
||||
@dataclass
|
||||
class BangOlufsenData:
|
||||
class BeoData:
|
||||
"""Dataclass for API client and WebSocket client."""
|
||||
|
||||
websocket: BangOlufsenWebsocket
|
||||
websocket: BeoWebsocket
|
||||
client: MozartClient
|
||||
|
||||
|
||||
type BangOlufsenConfigEntry = ConfigEntry[BangOlufsenData]
|
||||
type BeoConfigEntry = ConfigEntry[BeoData]
|
||||
|
||||
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
"""Set up from a config entry."""
|
||||
|
||||
# Remove casts to str
|
||||
assert entry.unique_id
|
||||
|
||||
# Create device now as BangOlufsenWebsocket needs a device for debug logging, firing events etc.
|
||||
# Create device now as BeoWebsocket needs a device for debug logging, firing events etc.
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
@@ -68,10 +68,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry)
|
||||
await client.close_api_client()
|
||||
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error
|
||||
|
||||
websocket = BangOlufsenWebsocket(hass, entry, client)
|
||||
websocket = BeoWebsocket(hass, entry, client)
|
||||
|
||||
# Add the websocket and API client
|
||||
entry.runtime_data = BangOlufsenData(websocket, client)
|
||||
entry.runtime_data = BeoData(websocket, client)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -82,9 +82,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: BangOlufsenConfigEntry
|
||||
) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
# Close the API client and WebSocket notification listener
|
||||
entry.runtime_data.client.disconnect_notifications()
|
||||
|
||||
@@ -47,7 +47,7 @@ _exception_map = {
|
||||
}
|
||||
|
||||
|
||||
class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
|
||||
_beolink_jid = ""
|
||||
|
||||
@@ -14,15 +14,19 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
|
||||
|
||||
class BangOlufsenSource:
|
||||
class BeoSource:
|
||||
"""Class used for associating device source ids with friendly names. May not include all sources."""
|
||||
|
||||
DEEZER: Final[Source] = Source(name="Deezer", id="deezer")
|
||||
LINE_IN: Final[Source] = Source(name="Line-In", id="lineIn")
|
||||
NET_RADIO: Final[Source] = Source(name="B&O Radio", id="netRadio")
|
||||
SPDIF: Final[Source] = Source(name="Optical", id="spdif")
|
||||
TIDAL: Final[Source] = Source(name="Tidal", id="tidal")
|
||||
UNKNOWN: Final[Source] = Source(name="Unknown Source", id="unknown")
|
||||
URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer")
|
||||
|
||||
|
||||
BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = {
|
||||
BEO_STATES: dict[str, MediaPlayerState] = {
|
||||
# Dict used for translating device states to Home Assistant states.
|
||||
"started": MediaPlayerState.PLAYING,
|
||||
"buffering": MediaPlayerState.PLAYING,
|
||||
@@ -36,19 +40,19 @@ BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = {
|
||||
}
|
||||
|
||||
# Dict used for translating Home Assistant settings to device repeat settings.
|
||||
BANG_OLUFSEN_REPEAT_FROM_HA: dict[RepeatMode, str] = {
|
||||
BEO_REPEAT_FROM_HA: dict[RepeatMode, str] = {
|
||||
RepeatMode.ALL: "all",
|
||||
RepeatMode.ONE: "track",
|
||||
RepeatMode.OFF: "none",
|
||||
}
|
||||
# Dict used for translating device repeat settings to Home Assistant settings.
|
||||
BANG_OLUFSEN_REPEAT_TO_HA: dict[str, RepeatMode] = {
|
||||
value: key for key, value in BANG_OLUFSEN_REPEAT_FROM_HA.items()
|
||||
BEO_REPEAT_TO_HA: dict[str, RepeatMode] = {
|
||||
value: key for key, value in BEO_REPEAT_FROM_HA.items()
|
||||
}
|
||||
|
||||
|
||||
# Media types for play_media
|
||||
class BangOlufsenMediaType(StrEnum):
|
||||
class BeoMediaType(StrEnum):
|
||||
"""Bang & Olufsen specific media types."""
|
||||
|
||||
FAVOURITE = "favourite"
|
||||
@@ -59,7 +63,7 @@ class BangOlufsenMediaType(StrEnum):
|
||||
OVERLAY_TTS = "overlay_tts"
|
||||
|
||||
|
||||
class BangOlufsenModel(StrEnum):
|
||||
class BeoModel(StrEnum):
|
||||
"""Enum for compatible model names."""
|
||||
|
||||
# Mozart devices
|
||||
@@ -78,8 +82,18 @@ class BangOlufsenModel(StrEnum):
|
||||
BEOREMOTE_ONE = "Beoremote One"
|
||||
|
||||
|
||||
class BeoAttribute(StrEnum):
|
||||
"""Enum for extra_state_attribute keys."""
|
||||
|
||||
BEOLINK = "beolink"
|
||||
BEOLINK_PEERS = "peers"
|
||||
BEOLINK_SELF = "self"
|
||||
BEOLINK_LEADER = "leader"
|
||||
BEOLINK_LISTENERS = "listeners"
|
||||
|
||||
|
||||
# Physical "buttons" on devices
|
||||
class BangOlufsenButtons(StrEnum):
|
||||
class BeoButtons(StrEnum):
|
||||
"""Enum for device buttons."""
|
||||
|
||||
BLUETOOTH = "Bluetooth"
|
||||
@@ -126,7 +140,7 @@ class WebsocketNotification(StrEnum):
|
||||
DOMAIN: Final[str] = "bang_olufsen"
|
||||
|
||||
# Default values for configuration.
|
||||
DEFAULT_MODEL: Final[str] = BangOlufsenModel.BEOSOUND_BALANCE
|
||||
DEFAULT_MODEL: Final[str] = BeoModel.BEOSOUND_BALANCE
|
||||
|
||||
# Configuration.
|
||||
CONF_SERIAL_NUMBER: Final = "serial_number"
|
||||
@@ -134,7 +148,7 @@ CONF_BEOLINK_JID: Final = "jid"
|
||||
|
||||
# Models to choose from in manual configuration.
|
||||
SELECTABLE_MODELS: list[str] = [
|
||||
model.value for model in BangOlufsenModel if model != BangOlufsenModel.BEOREMOTE_ONE
|
||||
model.value for model in BeoModel if model != BeoModel.BEOREMOTE_ONE
|
||||
]
|
||||
|
||||
MANUFACTURER: Final[str] = "Bang & Olufsen"
|
||||
@@ -146,15 +160,15 @@ ATTR_ITEM_NUMBER: Final[str] = "in"
|
||||
ATTR_FRIENDLY_NAME: Final[str] = "fn"
|
||||
|
||||
# Power states.
|
||||
BANG_OLUFSEN_ON: Final[str] = "on"
|
||||
BEO_ON: Final[str] = "on"
|
||||
|
||||
VALID_MEDIA_TYPES: Final[tuple] = (
|
||||
BangOlufsenMediaType.FAVOURITE,
|
||||
BangOlufsenMediaType.DEEZER,
|
||||
BangOlufsenMediaType.RADIO,
|
||||
BangOlufsenMediaType.TTS,
|
||||
BangOlufsenMediaType.TIDAL,
|
||||
BangOlufsenMediaType.OVERLAY_TTS,
|
||||
BeoMediaType.FAVOURITE,
|
||||
BeoMediaType.DEEZER,
|
||||
BeoMediaType.RADIO,
|
||||
BeoMediaType.TTS,
|
||||
BeoMediaType.TIDAL,
|
||||
BeoMediaType.OVERLAY_TTS,
|
||||
MediaType.MUSIC,
|
||||
MediaType.URL,
|
||||
MediaType.CHANNEL,
|
||||
@@ -232,7 +246,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
)
|
||||
|
||||
# Device events
|
||||
BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
|
||||
BEO_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
|
||||
|
||||
# Dict used to translate native Bang & Olufsen event names to string.json compatible ones
|
||||
EVENT_TRANSLATION_MAP: dict[str, str] = {
|
||||
@@ -249,7 +263,7 @@ EVENT_TRANSLATION_MAP: dict[str, str] = {
|
||||
|
||||
CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS"
|
||||
|
||||
DEVICE_BUTTONS: Final[list[str]] = [x.value for x in BangOlufsenButtons]
|
||||
DEVICE_BUTTONS: Final[list[str]] = [x.value for x in BeoButtons]
|
||||
|
||||
|
||||
DEVICE_BUTTON_EVENTS: Final[list[str]] = [
|
||||
|
||||
@@ -10,13 +10,13 @@ from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import BangOlufsenConfigEntry
|
||||
from . import BeoConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .util import get_device_buttons
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: BangOlufsenConfigEntry
|
||||
hass: HomeAssistant, config_entry: BeoConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ from homeassistant.helpers.entity import Entity
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class BangOlufsenBase:
|
||||
"""Base class for BangOlufsen Home Assistant objects."""
|
||||
class BeoBase:
|
||||
"""Base class for Bang & Olufsen Home Assistant objects."""
|
||||
|
||||
def __init__(self, entry: ConfigEntry, client: MozartClient) -> None:
|
||||
"""Initialize the object."""
|
||||
@@ -51,8 +51,8 @@ class BangOlufsenBase:
|
||||
)
|
||||
|
||||
|
||||
class BangOlufsenEntity(Entity, BangOlufsenBase):
|
||||
"""Base Entity for BangOlufsen entities."""
|
||||
class BeoEntity(Entity, BeoBase):
|
||||
"""Base Entity for Bang & Olufsen entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BangOlufsenConfigEntry
|
||||
from . import BeoConfigEntry
|
||||
from .const import (
|
||||
BEO_REMOTE_CONTROL_KEYS,
|
||||
BEO_REMOTE_KEY_EVENTS,
|
||||
@@ -25,10 +25,10 @@ from .const import (
|
||||
DEVICE_BUTTON_EVENTS,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
BangOlufsenModel,
|
||||
BeoModel,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BangOlufsenEntity
|
||||
from .entity import BeoEntity
|
||||
from .util import get_device_buttons, get_remotes
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -36,14 +36,14 @@ PARALLEL_UPDATES = 0
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BangOlufsenConfigEntry,
|
||||
config_entry: BeoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Event entities from config entry."""
|
||||
entities: list[BangOlufsenEvent] = []
|
||||
entities: list[BeoEvent] = []
|
||||
|
||||
async_add_entities(
|
||||
BangOlufsenButtonEvent(config_entry, button_type)
|
||||
BeoButtonEvent(config_entry, button_type)
|
||||
for button_type in get_device_buttons(config_entry.data[CONF_MODEL])
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ async def async_setup_entry(
|
||||
# Add Light keys
|
||||
entities.extend(
|
||||
[
|
||||
BangOlufsenRemoteKeyEvent(
|
||||
BeoRemoteKeyEvent(
|
||||
config_entry,
|
||||
remote,
|
||||
f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}",
|
||||
@@ -66,7 +66,7 @@ async def async_setup_entry(
|
||||
# Add Control keys
|
||||
entities.extend(
|
||||
[
|
||||
BangOlufsenRemoteKeyEvent(
|
||||
BeoRemoteKeyEvent(
|
||||
config_entry,
|
||||
remote,
|
||||
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}",
|
||||
@@ -84,10 +84,9 @@ async def async_setup_entry(
|
||||
config_entry.entry_id
|
||||
)
|
||||
for device in devices:
|
||||
if (
|
||||
device.model == BangOlufsenModel.BEOREMOTE_ONE
|
||||
and device.serial_number not in {remote.serial_number for remote in remotes}
|
||||
):
|
||||
if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in {
|
||||
remote.serial_number for remote in remotes
|
||||
}:
|
||||
device_registry.async_update_device(
|
||||
device.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
@@ -95,13 +94,13 @@ async def async_setup_entry(
|
||||
async_add_entities(new_entities=entities)
|
||||
|
||||
|
||||
class BangOlufsenEvent(BangOlufsenEntity, EventEntity):
|
||||
class BeoEvent(BeoEntity, EventEntity):
|
||||
"""Base Event class."""
|
||||
|
||||
_attr_device_class = EventDeviceClass.BUTTON
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def __init__(self, config_entry: BangOlufsenConfigEntry) -> None:
|
||||
def __init__(self, config_entry: BeoConfigEntry) -> None:
|
||||
"""Initialize Event."""
|
||||
super().__init__(config_entry, config_entry.runtime_data.client)
|
||||
|
||||
@@ -112,12 +111,12 @@ class BangOlufsenEvent(BangOlufsenEntity, EventEntity):
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class BangOlufsenButtonEvent(BangOlufsenEvent):
|
||||
class BeoButtonEvent(BeoEvent):
|
||||
"""Event class for Button events."""
|
||||
|
||||
_attr_event_types = DEVICE_BUTTON_EVENTS
|
||||
|
||||
def __init__(self, config_entry: BangOlufsenConfigEntry, button_type: str) -> None:
|
||||
def __init__(self, config_entry: BeoConfigEntry, button_type: str) -> None:
|
||||
"""Initialize Button."""
|
||||
super().__init__(config_entry)
|
||||
|
||||
@@ -146,14 +145,14 @@ class BangOlufsenButtonEvent(BangOlufsenEvent):
|
||||
)
|
||||
|
||||
|
||||
class BangOlufsenRemoteKeyEvent(BangOlufsenEvent):
|
||||
class BeoRemoteKeyEvent(BeoEvent):
|
||||
"""Event class for Beoremote One key events."""
|
||||
|
||||
_attr_event_types = BEO_REMOTE_KEY_EVENTS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: BangOlufsenConfigEntry,
|
||||
config_entry: BeoConfigEntry,
|
||||
remote: PairedRemote,
|
||||
key_type: str,
|
||||
) -> None:
|
||||
@@ -166,8 +165,8 @@ class BangOlufsenRemoteKeyEvent(BangOlufsenEvent):
|
||||
self._attr_unique_id = f"{remote.serial_number}_{self._unique_id}_{key_type}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
|
||||
name=f"{BangOlufsenModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}",
|
||||
model=BangOlufsenModel.BEOREMOTE_ONE,
|
||||
name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}",
|
||||
model=BeoModel.BEOREMOTE_ONE,
|
||||
serial_number=remote.serial_number,
|
||||
sw_version=remote.app_version,
|
||||
manufacturer=MANUFACTURER,
|
||||
|
||||
@@ -69,11 +69,11 @@ from homeassistant.helpers.entity_platform import (
|
||||
)
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from . import BangOlufsenConfigEntry
|
||||
from . import BeoConfigEntry
|
||||
from .const import (
|
||||
BANG_OLUFSEN_REPEAT_FROM_HA,
|
||||
BANG_OLUFSEN_REPEAT_TO_HA,
|
||||
BANG_OLUFSEN_STATES,
|
||||
BEO_REPEAT_FROM_HA,
|
||||
BEO_REPEAT_TO_HA,
|
||||
BEO_STATES,
|
||||
BEOLINK_JOIN_SOURCES,
|
||||
BEOLINK_JOIN_SOURCES_TO_UPPER,
|
||||
CONF_BEOLINK_JID,
|
||||
@@ -82,11 +82,12 @@ from .const import (
|
||||
FALLBACK_SOURCES,
|
||||
MANUFACTURER,
|
||||
VALID_MEDIA_TYPES,
|
||||
BangOlufsenMediaType,
|
||||
BangOlufsenSource,
|
||||
BeoAttribute,
|
||||
BeoMediaType,
|
||||
BeoSource,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BangOlufsenEntity
|
||||
from .entity import BeoEntity
|
||||
from .util import get_serial_number_from_jid
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -95,7 +96,7 @@ SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
BANG_OLUFSEN_FEATURES = (
|
||||
BEO_FEATURES = (
|
||||
MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
|
||||
| MediaPlayerEntityFeature.GROUPING
|
||||
@@ -118,15 +119,13 @@ BANG_OLUFSEN_FEATURES = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BangOlufsenConfigEntry,
|
||||
config_entry: BeoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a Media Player entity from config entry."""
|
||||
# Add MediaPlayer entity
|
||||
async_add_entities(
|
||||
new_entities=[
|
||||
BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client)
|
||||
],
|
||||
new_entities=[BeoMediaPlayer(config_entry, config_entry.runtime_data.client)],
|
||||
update_before_add=True,
|
||||
)
|
||||
|
||||
@@ -186,7 +185,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
"""Representation of a media player."""
|
||||
|
||||
_attr_name = None
|
||||
@@ -224,7 +223,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
# Beolink compatible sources
|
||||
self._beolink_sources: dict[str, bool] = {}
|
||||
self._remote_leader: BeolinkLeader | None = None
|
||||
# Extra state attributes for showing Beolink: peer(s), listener(s), leader and self
|
||||
# Extra state attributes:
|
||||
# Beolink: peer(s), listener(s), leader and self
|
||||
self._beolink_attributes: dict[str, dict[str, dict[str, str]]] = {}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
@@ -286,7 +286,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
queue_settings = await self._client.get_settings_queue(_request_timeout=5)
|
||||
|
||||
if queue_settings.repeat is not None:
|
||||
self._attr_repeat = BANG_OLUFSEN_REPEAT_TO_HA[queue_settings.repeat]
|
||||
self._attr_repeat = BEO_REPEAT_TO_HA[queue_settings.repeat]
|
||||
|
||||
if queue_settings.shuffle is not None:
|
||||
self._attr_shuffle = queue_settings.shuffle
|
||||
@@ -406,8 +406,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
|
||||
# Check if source is line-in or optical and progress should be updated
|
||||
if self._source_change.id in (
|
||||
BangOlufsenSource.LINE_IN.id,
|
||||
BangOlufsenSource.SPDIF.id,
|
||||
BeoSource.LINE_IN.id,
|
||||
BeoSource.SPDIF.id,
|
||||
):
|
||||
self._playback_progress = PlaybackProgress(progress=0)
|
||||
|
||||
@@ -436,7 +436,10 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
await self._async_update_beolink()
|
||||
|
||||
async def _async_update_beolink(self) -> None:
|
||||
"""Update the current Beolink leader, listeners, peers and self."""
|
||||
"""Update the current Beolink leader, listeners, peers and self.
|
||||
|
||||
Updates Home Assistant state.
|
||||
"""
|
||||
|
||||
self._beolink_attributes = {}
|
||||
|
||||
@@ -445,18 +448,22 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
|
||||
# Add Beolink self
|
||||
self._beolink_attributes = {
|
||||
"beolink": {"self": {self.device_entry.name: self._beolink_jid}}
|
||||
BeoAttribute.BEOLINK: {
|
||||
BeoAttribute.BEOLINK_SELF: {self.device_entry.name: self._beolink_jid}
|
||||
}
|
||||
}
|
||||
|
||||
# Add Beolink peers
|
||||
peers = await self._client.get_beolink_peers()
|
||||
|
||||
if len(peers) > 0:
|
||||
self._beolink_attributes["beolink"]["peers"] = {}
|
||||
self._beolink_attributes[BeoAttribute.BEOLINK][
|
||||
BeoAttribute.BEOLINK_PEERS
|
||||
] = {}
|
||||
for peer in peers:
|
||||
self._beolink_attributes["beolink"]["peers"][peer.friendly_name] = (
|
||||
peer.jid
|
||||
)
|
||||
self._beolink_attributes[BeoAttribute.BEOLINK][
|
||||
BeoAttribute.BEOLINK_PEERS
|
||||
][peer.friendly_name] = peer.jid
|
||||
|
||||
# Add Beolink listeners / leader
|
||||
self._remote_leader = self._playback_metadata.remote_leader
|
||||
@@ -477,7 +484,9 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
# Add self
|
||||
group_members.append(self.entity_id)
|
||||
|
||||
self._beolink_attributes["beolink"]["leader"] = {
|
||||
self._beolink_attributes[BeoAttribute.BEOLINK][
|
||||
BeoAttribute.BEOLINK_LEADER
|
||||
] = {
|
||||
self._remote_leader.friendly_name: self._remote_leader.jid,
|
||||
}
|
||||
|
||||
@@ -514,9 +523,9 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
beolink_listener.jid
|
||||
)
|
||||
break
|
||||
self._beolink_attributes["beolink"]["listeners"] = (
|
||||
beolink_listeners_attribute
|
||||
)
|
||||
self._beolink_attributes[BeoAttribute.BEOLINK][
|
||||
BeoAttribute.BEOLINK_LISTENERS
|
||||
] = beolink_listeners_attribute
|
||||
|
||||
self._attr_group_members = group_members
|
||||
|
||||
@@ -587,7 +596,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
"""Flag media player features that are supported."""
|
||||
features = BANG_OLUFSEN_FEATURES
|
||||
features = BEO_FEATURES
|
||||
|
||||
# Add seeking if supported by the current source
|
||||
if self._source_change.is_seekable is True:
|
||||
@@ -598,7 +607,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
"""Return the current state of the media player."""
|
||||
return BANG_OLUFSEN_STATES[self._state]
|
||||
return BEO_STATES[self._state]
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
@@ -615,11 +624,18 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_content_type(self) -> str:
|
||||
def media_content_type(self) -> MediaType | str | None:
|
||||
"""Return the current media type."""
|
||||
# Hard to determine content type
|
||||
if self._source_change.id == BangOlufsenSource.URI_STREAMER.id:
|
||||
return MediaType.URL
|
||||
content_type = {
|
||||
BeoSource.URI_STREAMER.id: MediaType.URL,
|
||||
BeoSource.DEEZER.id: BeoMediaType.DEEZER,
|
||||
BeoSource.TIDAL.id: BeoMediaType.TIDAL,
|
||||
BeoSource.NET_RADIO.id: BeoMediaType.RADIO,
|
||||
}
|
||||
# Hard to determine content type.
|
||||
if self._source_change.id in content_type:
|
||||
return content_type[self._source_change.id]
|
||||
|
||||
return MediaType.MUSIC
|
||||
|
||||
@property
|
||||
@@ -632,6 +648,11 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
"""Return the current playback progress."""
|
||||
return self._playback_progress.progress
|
||||
|
||||
@property
|
||||
def media_content_id(self) -> str | None:
|
||||
"""Return internal ID of Deezer, Tidal and radio stations."""
|
||||
return self._playback_metadata.source_internal_id
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Return URL of the currently playing music."""
|
||||
@@ -740,9 +761,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||
"""Set playback queues to repeat."""
|
||||
await self._client.set_settings_queue(
|
||||
play_queue_settings=PlayQueueSettings(
|
||||
repeat=BANG_OLUFSEN_REPEAT_FROM_HA[repeat]
|
||||
)
|
||||
play_queue_settings=PlayQueueSettings(repeat=BEO_REPEAT_FROM_HA[repeat])
|
||||
)
|
||||
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
@@ -846,7 +865,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
self._volume.level.level + offset_volume, 100
|
||||
)
|
||||
|
||||
if media_type == BangOlufsenMediaType.OVERLAY_TTS:
|
||||
if media_type == BeoMediaType.OVERLAY_TTS:
|
||||
# Bang & Olufsen cloud TTS
|
||||
overlay_play_request.text_to_speech = (
|
||||
OverlayPlayRequestTextToSpeechTextToSpeech(
|
||||
@@ -863,14 +882,14 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
|
||||
# The "provider" media_type may not be suitable for overlay all the time.
|
||||
# Use it for now.
|
||||
elif media_type == BangOlufsenMediaType.TTS:
|
||||
elif media_type == BeoMediaType.TTS:
|
||||
await self._client.post_overlay_play(
|
||||
overlay_play_request=OverlayPlayRequest(
|
||||
uri=Uri(location=media_id),
|
||||
)
|
||||
)
|
||||
|
||||
elif media_type == BangOlufsenMediaType.RADIO:
|
||||
elif media_type == BeoMediaType.RADIO:
|
||||
await self._client.run_provided_scene(
|
||||
scene_properties=SceneProperties(
|
||||
action_list=[
|
||||
@@ -882,13 +901,13 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
)
|
||||
)
|
||||
|
||||
elif media_type == BangOlufsenMediaType.FAVOURITE:
|
||||
elif media_type == BeoMediaType.FAVOURITE:
|
||||
await self._client.activate_preset(id=int(media_id))
|
||||
|
||||
elif media_type in (BangOlufsenMediaType.DEEZER, BangOlufsenMediaType.TIDAL):
|
||||
elif media_type in (BeoMediaType.DEEZER, BeoMediaType.TIDAL):
|
||||
try:
|
||||
# Play Deezer flow.
|
||||
if media_id == "flow" and media_type == BangOlufsenMediaType.DEEZER:
|
||||
if media_id == "flow" and media_type == BeoMediaType.DEEZER:
|
||||
deezer_id = None
|
||||
|
||||
if "id" in kwargs[ATTR_MEDIA_EXTRA]:
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from .const import DEVICE_BUTTONS, DOMAIN, BangOlufsenButtons, BangOlufsenModel
|
||||
from .const import DEVICE_BUTTONS, DOMAIN, BeoButtons, BeoModel
|
||||
|
||||
|
||||
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
|
||||
@@ -40,16 +40,16 @@ async def get_remotes(client: MozartClient) -> list[PairedRemote]:
|
||||
]
|
||||
|
||||
|
||||
def get_device_buttons(model: BangOlufsenModel) -> list[str]:
|
||||
def get_device_buttons(model: BeoModel) -> list[str]:
|
||||
"""Get supported buttons for a given model."""
|
||||
buttons = DEVICE_BUTTONS.copy()
|
||||
|
||||
# Beosound Premiere does not have a bluetooth button
|
||||
if model == BangOlufsenModel.BEOSOUND_PREMIERE:
|
||||
buttons.remove(BangOlufsenButtons.BLUETOOTH)
|
||||
if model == BeoModel.BEOSOUND_PREMIERE:
|
||||
buttons.remove(BeoButtons.BLUETOOTH)
|
||||
|
||||
# Beoconnect Core does not have any buttons
|
||||
elif model == BangOlufsenModel.BEOCONNECT_CORE:
|
||||
elif model == BeoModel.BEOCONNECT_CORE:
|
||||
buttons = []
|
||||
|
||||
return buttons
|
||||
|
||||
@@ -27,20 +27,20 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from .const import (
|
||||
BANG_OLUFSEN_WEBSOCKET_EVENT,
|
||||
BEO_WEBSOCKET_EVENT,
|
||||
CONNECTION_STATUS,
|
||||
DOMAIN,
|
||||
EVENT_TRANSLATION_MAP,
|
||||
BangOlufsenModel,
|
||||
BeoModel,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BangOlufsenBase
|
||||
from .entity import BeoBase
|
||||
from .util import get_device, get_remotes
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
class BeoWebsocket(BeoBase):
|
||||
"""The WebSocket listeners."""
|
||||
|
||||
def __init__(
|
||||
@@ -48,7 +48,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
) -> None:
|
||||
"""Initialize the WebSocket listeners."""
|
||||
|
||||
BangOlufsenBase.__init__(self, entry, client)
|
||||
BeoBase.__init__(self, entry, client)
|
||||
|
||||
self.hass = hass
|
||||
self._device = get_device(hass, self._unique_id)
|
||||
@@ -178,7 +178,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
self.entry.entry_id
|
||||
)
|
||||
if device.serial_number is not None
|
||||
and device.model == BangOlufsenModel.BEOREMOTE_ONE
|
||||
and device.model == BeoModel.BEOREMOTE_ONE
|
||||
]
|
||||
# Get paired remotes from device
|
||||
remote_serial_numbers = [
|
||||
@@ -274,4 +274,4 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
}
|
||||
|
||||
_LOGGER.debug("%s", debug_notification)
|
||||
self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, debug_notification)
|
||||
self.hass.bus.async_fire(BEO_WEBSOCKET_EVENT, debug_notification)
|
||||
|
||||
@@ -174,5 +174,13 @@
|
||||
"on": "mdi:window-open"
|
||||
}
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"occupancy_cleared": {
|
||||
"trigger": "mdi:home-outline"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"trigger": "mdi:home"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description_occupancy": "The behavior of the targeted occupancy sensors to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"condition_type": {
|
||||
"is_bat_low": "{entity_name} battery is low",
|
||||
@@ -317,5 +321,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Binary sensor"
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Binary sensor",
|
||||
"triggers": {
|
||||
"occupancy_cleared": {
|
||||
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
|
||||
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Occupancy cleared"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
|
||||
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Occupancy detected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
67
homeassistant/components/binary_sensor/trigger.py
Normal file
67
homeassistant/components/binary_sensor/trigger.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Provides triggers for binary sensors."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from . import DOMAIN, BinarySensorDeviceClass
|
||||
|
||||
|
||||
def get_device_class_or_undefined(
|
||||
hass: HomeAssistant, entity_id: str
|
||||
) -> str | None | UndefinedType:
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
try:
|
||||
return get_device_class(hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
class BinarySensorOnOffTrigger(EntityStateTriggerBase):
|
||||
"""Class for binary sensor on/off triggers."""
|
||||
|
||||
_device_class: BinarySensorDeviceClass | None
|
||||
_domain: str = DOMAIN
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if get_device_class_or_undefined(self._hass, entity_id)
|
||||
== self._device_class
|
||||
}
|
||||
|
||||
|
||||
def make_binary_sensor_trigger(
|
||||
device_class: BinarySensorDeviceClass | None,
|
||||
to_state: str,
|
||||
) -> type[BinarySensorOnOffTrigger]:
|
||||
"""Create an entity state trigger class."""
|
||||
|
||||
class CustomTrigger(BinarySensorOnOffTrigger):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_device_class = device_class
|
||||
_to_state = to_state
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"occupancy_detected": make_binary_sensor_trigger(
|
||||
BinarySensorDeviceClass.OCCUPANCY, STATE_ON
|
||||
),
|
||||
"occupancy_cleared": make_binary_sensor_trigger(
|
||||
BinarySensorDeviceClass.OCCUPANCY, STATE_OFF
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for binary sensors."""
|
||||
return TRIGGERS
|
||||
25
homeassistant/components/binary_sensor/triggers.yaml
Normal file
25
homeassistant/components/binary_sensor/triggers.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
occupancy_cleared:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
device_class: occupancy
|
||||
|
||||
occupancy_detected:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
device_class: occupancy
|
||||
@@ -15,12 +15,12 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==1.0.1",
|
||||
"bleak==2.0.0",
|
||||
"bleak-retry-connector==4.4.3",
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.7.0"
|
||||
"habluetooth==5.8.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -68,9 +68,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
|
||||
config_entry_id=entry.entry_id,
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
|
||||
identifiers={(DOMAIN, entry.unique_id or entry.entry_id)},
|
||||
name=f"Bosch {panel.model}",
|
||||
name=f"Bosch {panel.model.name}",
|
||||
manufacturer="Bosch Security Systems",
|
||||
model=panel.model,
|
||||
model=panel.model.name,
|
||||
sw_version=panel.firmware_version,
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -83,7 +83,7 @@ async def try_connect(
|
||||
finally:
|
||||
await panel.disconnect()
|
||||
|
||||
return (panel.model, panel.serial_number)
|
||||
return (panel.model.name, panel.serial_number)
|
||||
|
||||
|
||||
class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
@@ -20,7 +20,8 @@ async def async_get_config_entry_diagnostics(
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": {
|
||||
"model": entry.runtime_data.model,
|
||||
"model": entry.runtime_data.model.name,
|
||||
"family": entry.runtime_data.model.family.name,
|
||||
"serial_number": entry.runtime_data.serial_number,
|
||||
"protocol_version": entry.runtime_data.protocol_version,
|
||||
"firmware_version": entry.runtime_data.firmware_version,
|
||||
|
||||
@@ -26,7 +26,7 @@ class BoschAlarmEntity(Entity):
|
||||
self._attr_should_poll = False
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=f"Bosch {panel.model}",
|
||||
name=f"Bosch {panel.model.name}",
|
||||
manufacturer="Bosch Security Systems",
|
||||
)
|
||||
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["bosch-alarm-mode2==0.4.6"]
|
||||
"requirements": ["bosch-alarm-mode2==0.4.10"]
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/broadlink",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["broadlink"],
|
||||
"requirements": ["broadlink==0.19.0"]
|
||||
|
||||
@@ -20,7 +20,7 @@ from aiohttp import hdrs, web
|
||||
import attr
|
||||
from propcache.api import cached_property, under_cached_property
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCIceCandidateInit, RTCIceServer
|
||||
from webrtc_models import RTCIceCandidateInit
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
@@ -37,6 +37,7 @@ from homeassistant.components.stream import (
|
||||
Stream,
|
||||
create_stream,
|
||||
)
|
||||
from homeassistant.components.web_rtc import async_get_ice_servers
|
||||
from homeassistant.components.websocket_api import ActiveConnection
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -84,7 +85,6 @@ from .prefs import (
|
||||
get_dynamic_camera_stream_settings,
|
||||
)
|
||||
from .webrtc import (
|
||||
DATA_ICE_SERVERS,
|
||||
CameraWebRTCProvider,
|
||||
WebRTCAnswer, # noqa: F401
|
||||
WebRTCCandidate, # noqa: F401
|
||||
@@ -93,7 +93,6 @@ from .webrtc import (
|
||||
WebRTCMessage, # noqa: F401
|
||||
WebRTCSendMessage,
|
||||
async_get_supported_provider,
|
||||
async_register_ice_servers,
|
||||
async_register_webrtc_provider, # noqa: F401
|
||||
async_register_ws,
|
||||
)
|
||||
@@ -400,20 +399,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
SERVICE_RECORD, CAMERA_SERVICE_RECORD, async_handle_record_service
|
||||
)
|
||||
|
||||
@callback
|
||||
def get_ice_servers() -> list[RTCIceServer]:
|
||||
if hass.config.webrtc.ice_servers:
|
||||
return hass.config.webrtc.ice_servers
|
||||
return [
|
||||
RTCIceServer(
|
||||
urls=[
|
||||
"stun:stun.home-assistant.io:80",
|
||||
"stun:stun.home-assistant.io:3478",
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
async_register_ice_servers(hass, get_ice_servers)
|
||||
return True
|
||||
|
||||
|
||||
@@ -731,11 +716,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Return the WebRTC client configuration and extend it with the registered ice servers."""
|
||||
config = self._async_get_webrtc_client_configuration()
|
||||
|
||||
ice_servers = [
|
||||
server
|
||||
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
|
||||
for server in servers()
|
||||
]
|
||||
ice_servers = async_get_ice_servers(self.hass)
|
||||
config.configuration.ice_servers.extend(ice_servers)
|
||||
|
||||
return config
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Camera",
|
||||
"after_dependencies": ["media_player"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"dependencies": ["http"],
|
||||
"dependencies": ["http", "web_rtc"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/camera",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from functools import cache, partial, wraps
|
||||
import logging
|
||||
@@ -12,12 +12,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mashumaro import MissingField
|
||||
import voluptuous as vol
|
||||
from webrtc_models import (
|
||||
RTCConfiguration,
|
||||
RTCIceCandidate,
|
||||
RTCIceCandidateInit,
|
||||
RTCIceServer,
|
||||
)
|
||||
from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceCandidateInit
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -38,9 +33,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
|
||||
"camera_webrtc_providers"
|
||||
)
|
||||
DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey(
|
||||
"camera_webrtc_ice_servers"
|
||||
)
|
||||
|
||||
|
||||
_WEBRTC = "WebRTC"
|
||||
@@ -367,21 +359,3 @@ async def async_get_supported_provider(
|
||||
return provider
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_ice_servers(
|
||||
hass: HomeAssistant,
|
||||
get_ice_server_fn: Callable[[], Iterable[RTCIceServer]],
|
||||
) -> Callable[[], None]:
|
||||
"""Register a ICE server.
|
||||
|
||||
The registering integration is responsible to implement caching if needed.
|
||||
"""
|
||||
servers = hass.data.setdefault(DATA_ICE_SERVERS, [])
|
||||
|
||||
def remove() -> None:
|
||||
servers.remove(get_ice_server_fn)
|
||||
|
||||
servers.append(get_ice_server_fn)
|
||||
return remove
|
||||
|
||||
@@ -299,59 +299,54 @@
|
||||
"title": "Climate",
|
||||
"triggers": {
|
||||
"started_cooling": {
|
||||
"description": "Triggers when a climate started cooling.",
|
||||
"description_configured": "[%key:component::climate::triggers::started_cooling::description%]",
|
||||
"description": "Triggers after one or more climate-control devices start cooling.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a climate started cooling"
|
||||
"name": "Climate-control device started cooling"
|
||||
},
|
||||
"started_drying": {
|
||||
"description": "Triggers when a climate started drying.",
|
||||
"description_configured": "[%key:component::climate::triggers::started_drying::description%]",
|
||||
"description": "Triggers after one or more climate-control devices start drying.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a climate started drying"
|
||||
"name": "Climate-control device started drying"
|
||||
},
|
||||
"started_heating": {
|
||||
"description": "Triggers when a climate starts to heat.",
|
||||
"description_configured": "[%key:component::climate::triggers::started_heating::description%]",
|
||||
"description": "Triggers after one or more climate-control devices start heating.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a climate starts to heat"
|
||||
"name": "Climate-control device started heating"
|
||||
},
|
||||
"turned_off": {
|
||||
"description": "Triggers when a climate is turned off.",
|
||||
"description_configured": "[%key:component::climate::triggers::turned_off::description%]",
|
||||
"description": "Triggers after one or more climate-control devices turn off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a climate is turned off"
|
||||
"name": "Climate-control device turned off"
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers when a climate is turned on.",
|
||||
"description_configured": "[%key:component::climate::triggers::turned_on::description%]",
|
||||
"description": "Triggers after one or more climate-control devices turn on, regardless of the mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a climate is turned on"
|
||||
"name": "Climate-control device turned on"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from hass_nabucasa import Cloud
|
||||
from hass_nabucasa import Cloud, NabuCasaBaseError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import alexa, google_assistant
|
||||
@@ -78,13 +79,16 @@ from .subscription import async_subscription_info
|
||||
DEFAULT_MODE = MODE_PROD
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.AI_TASK,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CONVERSATION,
|
||||
Platform.STT,
|
||||
Platform.TTS,
|
||||
]
|
||||
|
||||
LLM_PLATFORMS = [
|
||||
Platform.AI_TASK,
|
||||
Platform.CONVERSATION,
|
||||
]
|
||||
|
||||
SERVICE_REMOTE_CONNECT = "remote_connect"
|
||||
SERVICE_REMOTE_DISCONNECT = "remote_disconnect"
|
||||
|
||||
@@ -431,7 +435,14 @@ def _handle_prefs_updated(hass: HomeAssistant, cloud: Cloud[CloudClient]) -> Non
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
platforms = PLATFORMS.copy()
|
||||
if (cloud := hass.data[DATA_CLOUD]).is_logged_in:
|
||||
with suppress(NabuCasaBaseError):
|
||||
await cloud.llm.async_ensure_token()
|
||||
platforms += LLM_PLATFORMS
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, platforms)
|
||||
entry.runtime_data = {"platforms": platforms}
|
||||
stt_tts_entities_added = hass.data[DATA_PLATFORMS_SETUP]["stt_tts_entities_added"]
|
||||
stt_tts_entities_added.set()
|
||||
|
||||
@@ -440,7 +451,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
return await hass.config_entries.async_unload_platforms(
|
||||
entry, entry.runtime_data["platforms"]
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
|
||||
@@ -6,7 +6,6 @@ import io
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
|
||||
from hass_nabucasa import NabuCasaBaseError
|
||||
from hass_nabucasa.llm import (
|
||||
LLMAuthenticationError,
|
||||
LLMError,
|
||||
@@ -20,7 +19,7 @@ from PIL import Image
|
||||
from homeassistant.components import ai_task, conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
@@ -94,17 +93,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Home Assistant Cloud AI Task entity."""
|
||||
if not (cloud := hass.data[DATA_CLOUD]).is_logged_in:
|
||||
return
|
||||
try:
|
||||
await cloud.llm.async_ensure_token()
|
||||
except (LLMError, NabuCasaBaseError):
|
||||
return
|
||||
|
||||
async_add_entities([CloudLLMTaskEntity(cloud, config_entry)])
|
||||
cloud = hass.data[DATA_CLOUD]
|
||||
async_add_entities([CloudAITaskEntity(cloud, config_entry)])
|
||||
|
||||
|
||||
class CloudLLMTaskEntity(ai_task.AITaskEntity, BaseCloudLLMEntity):
|
||||
class CloudAITaskEntity(BaseCloudLLMEntity, ai_task.AITaskEntity):
|
||||
"""Home Assistant Cloud AI Task entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -181,7 +174,7 @@ class CloudLLMTaskEntity(ai_task.AITaskEntity, BaseCloudLLMEntity):
|
||||
attachments=attachments,
|
||||
)
|
||||
except LLMAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed("Cloud LLM authentication failed") from err
|
||||
raise HomeAssistantError("Cloud LLM authentication failed") from err
|
||||
except LLMRateLimitError as err:
|
||||
raise HomeAssistantError("Cloud LLM is rate limited") from err
|
||||
except LLMResponseError as err:
|
||||
|
||||
@@ -19,8 +19,8 @@ from homeassistant.components.alexa import (
|
||||
errors as alexa_errors,
|
||||
smart_home as alexa_smart_home,
|
||||
)
|
||||
from homeassistant.components.camera import async_register_ice_servers
|
||||
from homeassistant.components.google_assistant import smart_home as ga
|
||||
from homeassistant.components.web_rtc import async_register_ice_servers
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import Context, HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE
|
||||
@@ -71,6 +71,7 @@ class CloudClient(Interface):
|
||||
self._google_config_init_lock = asyncio.Lock()
|
||||
self._relayer_region: str | None = None
|
||||
self._cloud_ice_servers_listener: Callable[[], None] | None = None
|
||||
self._ice_servers: list[RTCIceServer] = []
|
||||
|
||||
@property
|
||||
def base_path(self) -> Path:
|
||||
@@ -117,6 +118,11 @@ class CloudClient(Interface):
|
||||
"""Return the connected relayer region."""
|
||||
return self._relayer_region
|
||||
|
||||
@property
|
||||
def ice_servers(self) -> list[RTCIceServer]:
|
||||
"""Return the current ICE servers."""
|
||||
return self._ice_servers
|
||||
|
||||
async def get_alexa_config(self) -> alexa_config.CloudAlexaConfig:
|
||||
"""Return Alexa config."""
|
||||
if self._alexa_config is None:
|
||||
@@ -203,11 +209,8 @@ class CloudClient(Interface):
|
||||
ice_servers: list[RTCIceServer],
|
||||
) -> Callable[[], None]:
|
||||
"""Register cloud ice server."""
|
||||
|
||||
def get_ice_servers() -> list[RTCIceServer]:
|
||||
return ice_servers
|
||||
|
||||
return async_register_ice_servers(self._hass, get_ice_servers)
|
||||
self._ice_servers = ice_servers
|
||||
return async_register_ice_servers(self._hass, lambda: self._ice_servers)
|
||||
|
||||
async def async_register_cloud_ice_servers_listener(
|
||||
prefs: CloudPreferences,
|
||||
@@ -268,6 +271,7 @@ class CloudClient(Interface):
|
||||
|
||||
async def logout_cleanups(self) -> None:
|
||||
"""Cleanup some stuff after logout."""
|
||||
self._ice_servers = []
|
||||
await self.prefs.async_set_username(None)
|
||||
|
||||
if self._alexa_config:
|
||||
|
||||
@@ -4,9 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from hass_nabucasa import NabuCasaBaseError
|
||||
from hass_nabucasa.llm import LLMError
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import MATCH_ALL
|
||||
@@ -24,19 +21,13 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Home Assistant Cloud conversation entity."""
|
||||
if not (cloud := hass.data[DATA_CLOUD]).is_logged_in:
|
||||
return
|
||||
try:
|
||||
await cloud.llm.async_ensure_token()
|
||||
except (LLMError, NabuCasaBaseError):
|
||||
return
|
||||
|
||||
cloud = hass.data[DATA_CLOUD]
|
||||
async_add_entities([CloudConversationEntity(cloud, config_entry)])
|
||||
|
||||
|
||||
class CloudConversationEntity(
|
||||
conversation.ConversationEntity,
|
||||
BaseCloudLLMEntity,
|
||||
conversation.ConversationEntity,
|
||||
):
|
||||
"""Home Assistant Cloud conversation agent."""
|
||||
|
||||
|
||||
@@ -8,10 +8,9 @@ import logging
|
||||
import re
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from hass_nabucasa import Cloud
|
||||
from hass_nabucasa import Cloud, NabuCasaBaseError
|
||||
from hass_nabucasa.llm import (
|
||||
LLMAuthenticationError,
|
||||
LLMError,
|
||||
LLMRateLimitError,
|
||||
LLMResponseError,
|
||||
LLMServiceError,
|
||||
@@ -37,7 +36,7 @@ from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.util import slugify
|
||||
@@ -562,7 +561,7 @@ class BaseCloudLLMEntity(Entity):
|
||||
"schema": _format_structured_output(
|
||||
structure, chat_log.llm_api
|
||||
),
|
||||
"strict": True,
|
||||
"strict": False,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -601,14 +600,14 @@ class BaseCloudLLMEntity(Entity):
|
||||
)
|
||||
|
||||
except LLMAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed("Cloud LLM authentication failed") from err
|
||||
raise HomeAssistantError("Cloud LLM authentication failed") from err
|
||||
except LLMRateLimitError as err:
|
||||
raise HomeAssistantError("Cloud LLM is rate limited") from err
|
||||
except LLMResponseError as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
except LLMServiceError as err:
|
||||
raise HomeAssistantError("Error talking to Cloud LLM") from err
|
||||
except LLMError as err:
|
||||
except NabuCasaBaseError as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
|
||||
@@ -99,6 +99,7 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_hook_delete)
|
||||
websocket_api.async_register_command(hass, websocket_remote_connect)
|
||||
websocket_api.async_register_command(hass, websocket_remote_disconnect)
|
||||
websocket_api.async_register_command(hass, websocket_webrtc_ice_servers)
|
||||
|
||||
websocket_api.async_register_command(hass, google_assistant_get)
|
||||
websocket_api.async_register_command(hass, google_assistant_list)
|
||||
@@ -1107,6 +1108,7 @@ async def alexa_sync(
|
||||
|
||||
|
||||
@websocket_api.websocket_command({"type": "cloud/tts/info"})
|
||||
@callback
|
||||
def tts_info(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
@@ -1134,3 +1136,22 @@ def tts_info(
|
||||
)
|
||||
|
||||
connection.send_result(msg["id"], {"languages": result})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "cloud/webrtc/ice_servers",
|
||||
}
|
||||
)
|
||||
@_require_cloud_login
|
||||
@callback
|
||||
def websocket_webrtc_ice_servers(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Handle get WebRTC ICE servers websocket command."""
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
[server.to_dict() for server in hass.data[DATA_CLOUD].client.ice_servers],
|
||||
)
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
"google_assistant"
|
||||
],
|
||||
"codeowners": ["@home-assistant/cloud"],
|
||||
"dependencies": ["auth", "http", "repairs", "webhook"],
|
||||
"dependencies": ["auth", "http", "repairs", "webhook", "web_rtc"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/cloud",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.6.2"],
|
||||
"requirements": ["hass-nabucasa==1.7.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -236,7 +236,9 @@ async def async_prepare_agent(
|
||||
|
||||
|
||||
async def async_handle_sentence_triggers(
|
||||
hass: HomeAssistant, user_input: ConversationInput
|
||||
hass: HomeAssistant,
|
||||
user_input: ConversationInput,
|
||||
chat_log: ChatLog,
|
||||
) -> str | None:
|
||||
"""Try to match input against sentence triggers and return response text.
|
||||
|
||||
@@ -245,12 +247,13 @@ async def async_handle_sentence_triggers(
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
return await agent.async_handle_sentence_triggers(user_input)
|
||||
return await agent.async_handle_sentence_triggers(user_input, chat_log)
|
||||
|
||||
|
||||
async def async_handle_intents(
|
||||
hass: HomeAssistant,
|
||||
user_input: ConversationInput,
|
||||
chat_log: ChatLog,
|
||||
*,
|
||||
intent_filter: Callable[[RecognizeResult], bool] | None = None,
|
||||
) -> intent.IntentResponse | None:
|
||||
@@ -261,7 +264,9 @@ async def async_handle_intents(
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
return await agent.async_handle_intents(user_input, intent_filter=intent_filter)
|
||||
return await agent.async_handle_intents(
|
||||
user_input, chat_log, intent_filter=intent_filter
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
@@ -431,26 +431,14 @@ class DefaultAgent(ConversationEntity):
|
||||
) -> ConversationResult:
|
||||
"""Handle a message."""
|
||||
response: intent.IntentResponse | None = None
|
||||
tool_input: llm.ToolInput | None = None
|
||||
tool_result: dict[str, Any] = {}
|
||||
|
||||
# Check if a trigger matched
|
||||
if trigger_result := await self.async_recognize_sentence_trigger(user_input):
|
||||
# Process callbacks and get response
|
||||
response_text = await self._handle_trigger_result(
|
||||
trigger_result, user_input
|
||||
trigger_result, user_input, chat_log
|
||||
)
|
||||
|
||||
# Create tool result
|
||||
tool_input = llm.ToolInput(
|
||||
tool_name="trigger_sentence",
|
||||
tool_args={},
|
||||
external=True,
|
||||
)
|
||||
tool_result = {
|
||||
"response": response_text,
|
||||
}
|
||||
|
||||
# Convert to conversation result
|
||||
response = intent.IntentResponse(
|
||||
language=user_input.language or self.hass.config.language
|
||||
@@ -462,40 +450,7 @@ class DefaultAgent(ConversationEntity):
|
||||
intent_result = await self.async_recognize_intent(user_input)
|
||||
|
||||
response = await self._async_process_intent_result(
|
||||
intent_result, user_input
|
||||
)
|
||||
|
||||
if response.response_type != intent.IntentResponseType.ERROR:
|
||||
assert intent_result is not None
|
||||
assert intent_result.intent is not None
|
||||
# Create external tool call for the intent
|
||||
tool_input = llm.ToolInput(
|
||||
tool_name=intent_result.intent.name,
|
||||
tool_args={
|
||||
entity.name: entity.value or entity.text
|
||||
for entity in intent_result.entities_list
|
||||
},
|
||||
external=True,
|
||||
)
|
||||
# Create tool result from intent response
|
||||
tool_result = llm.IntentResponseDict(response)
|
||||
|
||||
# Add tool call and result to chat log if we have one
|
||||
if tool_input is not None:
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
AssistantContent(
|
||||
agent_id=user_input.agent_id,
|
||||
content=None,
|
||||
tool_calls=[tool_input],
|
||||
)
|
||||
)
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
ToolResultContent(
|
||||
agent_id=user_input.agent_id,
|
||||
tool_call_id=tool_input.id,
|
||||
tool_name=tool_input.tool_name,
|
||||
tool_result=tool_result,
|
||||
)
|
||||
intent_result, user_input, chat_log
|
||||
)
|
||||
|
||||
speech: str = response.speech.get("plain", {}).get("speech", "")
|
||||
@@ -514,6 +469,7 @@ class DefaultAgent(ConversationEntity):
|
||||
self,
|
||||
result: RecognizeResult | None,
|
||||
user_input: ConversationInput,
|
||||
chat_log: ChatLog,
|
||||
) -> intent.IntentResponse:
|
||||
"""Process user input with intents."""
|
||||
language = user_input.language or self.hass.config.language
|
||||
@@ -576,12 +532,21 @@ class DefaultAgent(ConversationEntity):
|
||||
ConversationTraceEventType.TOOL_CALL,
|
||||
{
|
||||
"intent_name": result.intent.name,
|
||||
"slots": {
|
||||
entity.name: entity.value or entity.text
|
||||
for entity in result.entities_list
|
||||
},
|
||||
"slots": {entity.name: entity.value for entity in result.entities_list},
|
||||
},
|
||||
)
|
||||
tool_input = llm.ToolInput(
|
||||
tool_name=result.intent.name,
|
||||
tool_args={entity.name: entity.value for entity in result.entities_list},
|
||||
external=True,
|
||||
)
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
AssistantContent(
|
||||
agent_id=user_input.agent_id,
|
||||
content=None,
|
||||
tool_calls=[tool_input],
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
intent_response = await intent.async_handle(
|
||||
@@ -644,6 +609,16 @@ class DefaultAgent(ConversationEntity):
|
||||
)
|
||||
intent_response.async_set_speech(speech)
|
||||
|
||||
tool_result = llm.IntentResponseDict(intent_response)
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
ToolResultContent(
|
||||
agent_id=user_input.agent_id,
|
||||
tool_call_id=tool_input.id,
|
||||
tool_name=tool_input.tool_name,
|
||||
tool_result=tool_result,
|
||||
)
|
||||
)
|
||||
|
||||
return intent_response
|
||||
|
||||
def _recognize(
|
||||
@@ -1570,16 +1545,31 @@ class DefaultAgent(ConversationEntity):
|
||||
)
|
||||
|
||||
async def _handle_trigger_result(
|
||||
self, result: SentenceTriggerResult, user_input: ConversationInput
|
||||
self,
|
||||
result: SentenceTriggerResult,
|
||||
user_input: ConversationInput,
|
||||
chat_log: ChatLog,
|
||||
) -> str:
|
||||
"""Run sentence trigger callbacks and return response text."""
|
||||
|
||||
# Gather callback responses in parallel
|
||||
trigger_callbacks = [
|
||||
self._triggers_details[trigger_id].callback(user_input, trigger_result)
|
||||
for trigger_id, trigger_result in result.matched_triggers.items()
|
||||
]
|
||||
|
||||
tool_input = llm.ToolInput(
|
||||
tool_name="trigger_sentence",
|
||||
tool_args={},
|
||||
external=True,
|
||||
)
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
AssistantContent(
|
||||
agent_id=user_input.agent_id,
|
||||
content=None,
|
||||
tool_calls=[tool_input],
|
||||
)
|
||||
)
|
||||
|
||||
# Use first non-empty result as response.
|
||||
#
|
||||
# There may be multiple copies of a trigger running when editing in
|
||||
@@ -1608,23 +1598,38 @@ class DefaultAgent(ConversationEntity):
|
||||
f"component.{DOMAIN}.conversation.agent.done", "Done"
|
||||
)
|
||||
|
||||
tool_result: dict[str, Any] = {"response": response_text}
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
ToolResultContent(
|
||||
agent_id=user_input.agent_id,
|
||||
tool_call_id=tool_input.id,
|
||||
tool_name=tool_input.tool_name,
|
||||
tool_result=tool_result,
|
||||
)
|
||||
)
|
||||
|
||||
return response_text
|
||||
|
||||
async def async_handle_sentence_triggers(
|
||||
self, user_input: ConversationInput
|
||||
self,
|
||||
user_input: ConversationInput,
|
||||
chat_log: ChatLog,
|
||||
) -> str | None:
|
||||
"""Try to input sentence against sentence triggers and return response text.
|
||||
|
||||
Returns None if no match occurred.
|
||||
"""
|
||||
if trigger_result := await self.async_recognize_sentence_trigger(user_input):
|
||||
return await self._handle_trigger_result(trigger_result, user_input)
|
||||
return await self._handle_trigger_result(
|
||||
trigger_result, user_input, chat_log
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def async_handle_intents(
|
||||
self,
|
||||
user_input: ConversationInput,
|
||||
chat_log: ChatLog,
|
||||
*,
|
||||
intent_filter: Callable[[RecognizeResult], bool] | None = None,
|
||||
) -> intent.IntentResponse | None:
|
||||
@@ -1640,7 +1645,7 @@ class DefaultAgent(ConversationEntity):
|
||||
# No error message on failed match
|
||||
return None
|
||||
|
||||
response = await self._async_process_intent_result(result, user_input)
|
||||
response = await self._async_process_intent_result(result, user_input, chat_log)
|
||||
if (
|
||||
response.response_type == intent.IntentResponseType.ERROR
|
||||
and response.error_code
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.11.24"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2025.12.2"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ from typing import Any
|
||||
from pycoolmasternet_async import SWING_MODES
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
@@ -31,7 +35,16 @@ CM_TO_HA_STATE = {
|
||||
|
||||
HA_STATE_TO_CM = {value: key for key, value in CM_TO_HA_STATE.items()}
|
||||
|
||||
FAN_MODES = ["low", "med", "high", "auto"]
|
||||
CM_TO_HA_FAN = {
|
||||
"low": FAN_LOW,
|
||||
"med": FAN_MEDIUM,
|
||||
"high": FAN_HIGH,
|
||||
"auto": FAN_AUTO,
|
||||
}
|
||||
|
||||
HA_FAN_TO_CM = {value: key for key, value in CM_TO_HA_FAN.items()}
|
||||
|
||||
FAN_MODES = list(CM_TO_HA_FAN.values())
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -111,7 +124,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
|
||||
@property
|
||||
def fan_mode(self):
|
||||
"""Return the fan setting."""
|
||||
return self._unit.fan_speed
|
||||
return CM_TO_HA_FAN[self._unit.fan_speed]
|
||||
|
||||
@property
|
||||
def fan_modes(self):
|
||||
@@ -138,7 +151,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new fan mode."""
|
||||
_LOGGER.debug("Setting fan mode of %s to %s", self.unique_id, fan_mode)
|
||||
self._unit = await self._unit.set_fan_speed(fan_mode)
|
||||
self._unit = await self._unit.set_fan_speed(HA_FAN_TO_CM[fan_mode])
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
|
||||
@@ -108,34 +108,5 @@
|
||||
"toggle_cover_tilt": {
|
||||
"service": "mdi:arrow-top-right-bottom-left"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"awning_opened": {
|
||||
"trigger": "mdi:awning-outline"
|
||||
},
|
||||
"blind_opened": {
|
||||
"trigger": "mdi:blinds-horizontal"
|
||||
},
|
||||
"curtain_opened": {
|
||||
"trigger": "mdi:curtains"
|
||||
},
|
||||
"door_opened": {
|
||||
"trigger": "mdi:door-open"
|
||||
},
|
||||
"garage_opened": {
|
||||
"trigger": "mdi:garage-open"
|
||||
},
|
||||
"gate_opened": {
|
||||
"trigger": "mdi:gate-open"
|
||||
},
|
||||
"shade_opened": {
|
||||
"trigger": "mdi:roller-shade"
|
||||
},
|
||||
"shutter_opened": {
|
||||
"trigger": "mdi:window-shutter-open"
|
||||
},
|
||||
"window_opened": {
|
||||
"trigger": "mdi:window-open"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description_awning": "The behavior of the targeted awnings to trigger on.",
|
||||
"trigger_behavior_description_blind": "The behavior of the targeted blinds to trigger on.",
|
||||
"trigger_behavior_description_curtain": "The behavior of the targeted curtains to trigger on.",
|
||||
"trigger_behavior_description_door": "The behavior of the targeted doors to trigger on.",
|
||||
"trigger_behavior_description_garage": "The behavior of the targeted garage doors to trigger on.",
|
||||
"trigger_behavior_description_gate": "The behavior of the targeted gates to trigger on.",
|
||||
"trigger_behavior_description_shade": "The behavior of the targeted shades to trigger on.",
|
||||
"trigger_behavior_description_shutter": "The behavior of the targeted shutters to trigger on.",
|
||||
"trigger_behavior_description_window": "The behavior of the targeted windows to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"close": "Close {entity_name}",
|
||||
@@ -94,15 +82,6 @@
|
||||
"name": "Window"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"close_cover": {
|
||||
"description": "Closes a cover.",
|
||||
@@ -157,142 +136,5 @@
|
||||
"name": "Toggle tilt"
|
||||
}
|
||||
},
|
||||
"title": "Cover",
|
||||
"triggers": {
|
||||
"awning_opened": {
|
||||
"description": "Triggers when an awning opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::awning_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_awning%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the awnings to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When an awning opens"
|
||||
},
|
||||
"blind_opened": {
|
||||
"description": "Triggers when a blind opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::blind_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_blind%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the blinds to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a blind opens"
|
||||
},
|
||||
"curtain_opened": {
|
||||
"description": "Triggers when a curtain opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::curtain_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_curtain%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the curtains to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a curtain opens"
|
||||
},
|
||||
"door_opened": {
|
||||
"description": "Triggers when a door opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::door_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_door%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the doors to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a door opens"
|
||||
},
|
||||
"garage_opened": {
|
||||
"description": "Triggers when a garage door opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::garage_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_garage%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the garage doors to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a garage door opens"
|
||||
},
|
||||
"gate_opened": {
|
||||
"description": "Triggers when a gate opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::gate_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_gate%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the gates to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a gate opens"
|
||||
},
|
||||
"shade_opened": {
|
||||
"description": "Triggers when a shade opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::shade_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_shade%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the shades to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a shade opens"
|
||||
},
|
||||
"shutter_opened": {
|
||||
"description": "Triggers when a shutter opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::shutter_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_shutter%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the shutters to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a shutter opens"
|
||||
},
|
||||
"window_opened": {
|
||||
"description": "Triggers when a window opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::window_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_window%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the windows to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a window opens"
|
||||
}
|
||||
}
|
||||
"title": "Cover"
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Provides triggers for covers."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_OPTIONS
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
TriggerConfig,
|
||||
)
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from . import ATTR_CURRENT_POSITION, CoverDeviceClass, CoverState
|
||||
from .const import DOMAIN
|
||||
|
||||
ATTR_FULLY_OPENED: Final = "fully_opened"
|
||||
|
||||
COVER_OPENED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(ATTR_FULLY_OPENED, default=False): bool,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_device_class_or_undefined(
|
||||
hass: HomeAssistant, entity_id: str
|
||||
) -> str | None | UndefinedType:
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
try:
|
||||
return get_device_class(hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
class CoverOpenedClosedTrigger(EntityTriggerBase):
|
||||
"""Class for cover opened and closed triggers."""
|
||||
|
||||
_attribute: str = ATTR_CURRENT_POSITION
|
||||
_attribute_value: int | None = None
|
||||
_device_class: CoverDeviceClass | None
|
||||
_domain: str = DOMAIN
|
||||
_to_states: set[str]
|
||||
|
||||
def is_to_state(self, state: State) -> bool:
|
||||
"""Check if the state matches the target state."""
|
||||
if state.state not in self._to_states:
|
||||
return False
|
||||
if (
|
||||
self._attribute_value is not None
|
||||
and (value := state.attributes.get(self._attribute)) is not None
|
||||
and value != self._attribute_value
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if get_device_class_or_undefined(self._hass, entity_id)
|
||||
== self._device_class
|
||||
}
|
||||
|
||||
|
||||
class CoverOpenedTrigger(CoverOpenedClosedTrigger):
|
||||
"""Class for cover opened triggers."""
|
||||
|
||||
_schema = COVER_OPENED_TRIGGER_SCHEMA
|
||||
_to_states = {CoverState.OPEN, CoverState.OPENING}
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the state trigger."""
|
||||
super().__init__(hass, config)
|
||||
if self._options.get(ATTR_FULLY_OPENED):
|
||||
self._attribute_value = 100
|
||||
|
||||
|
||||
def make_cover_opened_trigger(
|
||||
device_class: CoverDeviceClass | None,
|
||||
) -> type[CoverOpenedTrigger]:
|
||||
"""Create an entity state attribute trigger class."""
|
||||
|
||||
class CustomTrigger(CoverOpenedTrigger):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_device_class = device_class
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"awning_opened": make_cover_opened_trigger(CoverDeviceClass.AWNING),
|
||||
"blind_opened": make_cover_opened_trigger(CoverDeviceClass.BLIND),
|
||||
"curtain_opened": make_cover_opened_trigger(CoverDeviceClass.CURTAIN),
|
||||
"door_opened": make_cover_opened_trigger(CoverDeviceClass.DOOR),
|
||||
"garage_opened": make_cover_opened_trigger(CoverDeviceClass.GARAGE),
|
||||
"gate_opened": make_cover_opened_trigger(CoverDeviceClass.GATE),
|
||||
"shade_opened": make_cover_opened_trigger(CoverDeviceClass.SHADE),
|
||||
"shutter_opened": make_cover_opened_trigger(CoverDeviceClass.SHUTTER),
|
||||
"window_opened": make_cover_opened_trigger(CoverDeviceClass.WINDOW),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for covers."""
|
||||
return TRIGGERS
|
||||
@@ -1,79 +0,0 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
fully_opened:
|
||||
required: true
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
awning_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: awning
|
||||
|
||||
blind_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: blind
|
||||
|
||||
curtain_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: curtain
|
||||
|
||||
door_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: door
|
||||
|
||||
garage_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: garage
|
||||
|
||||
gate_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: gate
|
||||
|
||||
shade_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: shade
|
||||
|
||||
shutter_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: shutter
|
||||
|
||||
window_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: window
|
||||
@@ -6,6 +6,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client"],
|
||||
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["async-upnp-client==0.46.0"],
|
||||
"ssdp": [
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==16.4.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.0"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aioecowitt==2025.9.2"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ DEFAULT_TTS_MODEL = "eleven_multilingual_v2"
|
||||
DEFAULT_STABILITY = 0.5
|
||||
DEFAULT_SIMILARITY = 0.75
|
||||
DEFAULT_STT_AUTO_LANGUAGE = False
|
||||
DEFAULT_STT_MODEL = "scribe_v1"
|
||||
DEFAULT_STT_MODEL = "scribe_v2"
|
||||
DEFAULT_STYLE = 0
|
||||
DEFAULT_USE_SPEAKER_BOOST = True
|
||||
|
||||
@@ -129,4 +129,5 @@ STT_LANGUAGES = [
|
||||
STT_MODELS = {
|
||||
"scribe_v1": "Scribe v1",
|
||||
"scribe_v1_experimental": "Scribe v1 Experimental",
|
||||
"scribe_v2": "Scribe v2 Realtime",
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.4.0"],
|
||||
"requirements": ["pyenphase==2.4.2"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -17,7 +17,7 @@ DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
|
||||
|
||||
DEFAULT_PORT: Final = 6053
|
||||
|
||||
STABLE_BLE_VERSION_STR = "2025.8.0"
|
||||
STABLE_BLE_VERSION_STR = "2025.11.0"
|
||||
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
|
||||
PROJECT_URLS = {
|
||||
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==42.8.0",
|
||||
"aioesphomeapi==42.10.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
"title": "[%key:component::assist_pipeline::issues::assist_in_progress_deprecated::title%]"
|
||||
},
|
||||
"ble_firmware_outdated": {
|
||||
"description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device from ESPHome earlier than 2022.12.0, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme.",
|
||||
"description": "ESPHome {version} introduces ultra-low latency event processing, reducing BLE event delays from 0-16 milliseconds to approximately 12 microseconds. This resolves stability issues when pairing, connecting, or handshaking with devices that require low latency, and makes Bluetooth proxy operations rival or exceed local adapters. We highly recommend updating {name} to take advantage of these improvements.",
|
||||
"title": "Update {name} with ESPHome {version} or later"
|
||||
},
|
||||
"device_conflict": {
|
||||
|
||||
@@ -166,26 +166,24 @@
|
||||
"title": "Fan",
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"description": "Triggers when a fan is turned off.",
|
||||
"description_configured": "[%key:component::fan::triggers::turned_off::description%]",
|
||||
"description": "Triggers after one or more fans turn off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::fan::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::fan::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a fan is turned off"
|
||||
"name": "Fan turned off"
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers when a fan is turned on.",
|
||||
"description_configured": "[%key:component::fan::triggers::turned_on::description%]",
|
||||
"description": "Triggers after one or more fans turn on.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::fan::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::fan::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a fan is turned on"
|
||||
"name": "Fan turned on"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
"""API for fitbit bound to Home Assistant OAuth."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from fitbit import Fitbit
|
||||
from fitbit.exceptions import HTTPException, HTTPUnauthorized
|
||||
from fitbit_web_api import ApiClient, Configuration, DevicesApi
|
||||
from fitbit_web_api.exceptions import (
|
||||
ApiException,
|
||||
OpenApiException,
|
||||
UnauthorizedException,
|
||||
)
|
||||
from fitbit_web_api.models.device import Device
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import FitbitUnitSystem
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import FitbitDevice, FitbitProfile
|
||||
from .model import FitbitProfile
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,6 +66,14 @@ class FitbitApi(ABC):
|
||||
expires_at=float(token[CONF_EXPIRES_AT]),
|
||||
)
|
||||
|
||||
async def _async_get_fitbit_web_api(self) -> ApiClient:
|
||||
"""Create and return an ApiClient configured with the current access token."""
|
||||
token = await self.async_get_access_token()
|
||||
configuration = Configuration()
|
||||
configuration.pool_manager = async_get_clientsession(self._hass)
|
||||
configuration.access_token = token[CONF_ACCESS_TOKEN]
|
||||
return ApiClient(configuration)
|
||||
|
||||
async def async_get_user_profile(self) -> FitbitProfile:
|
||||
"""Return the user profile from the API."""
|
||||
if self._profile is None:
|
||||
@@ -94,21 +110,13 @@ class FitbitApi(ABC):
|
||||
return FitbitUnitSystem.METRIC
|
||||
return FitbitUnitSystem.EN_US
|
||||
|
||||
async def async_get_devices(self) -> list[FitbitDevice]:
|
||||
"""Return available devices."""
|
||||
client = await self._async_get_client()
|
||||
devices: list[dict[str, str]] = await self._run(client.get_devices)
|
||||
async def async_get_devices(self) -> list[Device]:
|
||||
"""Return available devices using fitbit-web-api."""
|
||||
client = await self._async_get_fitbit_web_api()
|
||||
devices_api = DevicesApi(client)
|
||||
devices: list[Device] = await self._run_async(devices_api.get_devices)
|
||||
_LOGGER.debug("get_devices=%s", devices)
|
||||
return [
|
||||
FitbitDevice(
|
||||
id=device["id"],
|
||||
device_version=device["deviceVersion"],
|
||||
battery_level=int(device["batteryLevel"]),
|
||||
battery=device["battery"],
|
||||
type=device["type"],
|
||||
)
|
||||
for device in devices
|
||||
]
|
||||
return devices
|
||||
|
||||
async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]:
|
||||
"""Return the most recent value from the time series for the specified resource type."""
|
||||
@@ -140,6 +148,20 @@ class FitbitApi(ABC):
|
||||
_LOGGER.debug("Error from fitbit API: %s", err)
|
||||
raise FitbitApiException("Error from fitbit API") from err
|
||||
|
||||
async def _run_async[_T](self, func: Callable[[], Awaitable[_T]]) -> _T:
|
||||
"""Run client command."""
|
||||
try:
|
||||
return await func()
|
||||
except UnauthorizedException as err:
|
||||
_LOGGER.debug("Unauthorized error from fitbit API: %s", err)
|
||||
raise FitbitAuthException("Authentication error from fitbit API") from err
|
||||
except ApiException as err:
|
||||
_LOGGER.debug("Error from fitbit API: %s", err)
|
||||
raise FitbitApiException("Error from fitbit API") from err
|
||||
except OpenApiException as err:
|
||||
_LOGGER.debug("Error communicating with fitbit API: %s", err)
|
||||
raise FitbitApiException("Communication error from fitbit API") from err
|
||||
|
||||
|
||||
class OAuthFitbitApi(FitbitApi):
|
||||
"""Provide fitbit authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
@@ -6,6 +6,8 @@ import datetime
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from fitbit_web_api.models.device import Device
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
@@ -13,7 +15,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .api import FitbitApi
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import FitbitDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,7 +24,7 @@ TIMEOUT = 10
|
||||
type FitbitConfigEntry = ConfigEntry[FitbitData]
|
||||
|
||||
|
||||
class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]):
|
||||
class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
"""Coordinator for fetching fitbit devices from the API."""
|
||||
|
||||
config_entry: FitbitConfigEntry
|
||||
@@ -41,7 +42,7 @@ class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]):
|
||||
)
|
||||
self._api = api
|
||||
|
||||
async def _async_update_data(self) -> dict[str, FitbitDevice]:
|
||||
async def _async_update_data(self) -> dict[str, Device]:
|
||||
"""Fetch data from API endpoint."""
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
try:
|
||||
@@ -50,7 +51,7 @@ class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]):
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
except FitbitApiException as err:
|
||||
raise UpdateFailed(err) from err
|
||||
return {device.id: device for device in devices}
|
||||
return {device.id: device for device in devices if device.id is not None}
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"dependencies": ["application_credentials", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/fitbit",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["fitbit"],
|
||||
"requirements": ["fitbit==0.3.1"]
|
||||
"loggers": ["fitbit", "fitbit_web_api"],
|
||||
"requirements": ["fitbit==0.3.1", "fitbit-web-api==2.13.5"]
|
||||
}
|
||||
|
||||
@@ -21,26 +21,6 @@ class FitbitProfile:
|
||||
"""The locale defined in the user's Fitbit account settings."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitbitDevice:
|
||||
"""Device from the Fitbit API response."""
|
||||
|
||||
id: str
|
||||
"""The device ID."""
|
||||
|
||||
device_version: str
|
||||
"""The product name of the device."""
|
||||
|
||||
battery_level: int
|
||||
"""The battery level as a percentage."""
|
||||
|
||||
battery: str
|
||||
"""Returns the battery level of the device."""
|
||||
|
||||
type: str
|
||||
"""The type of the device such as TRACKER or SCALE."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitbitConfig:
|
||||
"""Information from the fitbit ConfigEntry data."""
|
||||
|
||||
@@ -8,6 +8,8 @@ import datetime
|
||||
import logging
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from fitbit_web_api.models.device import Device
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -32,7 +34,7 @@ from .api import FitbitApi
|
||||
from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem
|
||||
from .coordinator import FitbitConfigEntry, FitbitDeviceCoordinator
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import FitbitDevice, config_from_entry_data
|
||||
from .model import config_from_entry_data
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
@@ -657,7 +659,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
|
||||
coordinator: FitbitDeviceCoordinator,
|
||||
user_profile_id: str,
|
||||
description: FitbitSensorEntityDescription,
|
||||
device: FitbitDevice,
|
||||
device: Device,
|
||||
enable_default_override: bool,
|
||||
) -> None:
|
||||
"""Initialize the Fitbit sensor."""
|
||||
@@ -677,7 +679,9 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
"""Icon to use in the frontend, if any."""
|
||||
if battery_level := BATTERY_LEVELS.get(self.device.battery):
|
||||
if self.device.battery is not None and (
|
||||
battery_level := BATTERY_LEVELS.get(self.device.battery)
|
||||
):
|
||||
return icon_for_battery_level(battery_level=battery_level)
|
||||
return self.entity_description.icon
|
||||
|
||||
@@ -697,7 +701,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.device = self.coordinator.data[self.device.id]
|
||||
self.device = self.coordinator.data[cast(str, self.device.id)]
|
||||
self._attr_native_value = self.device.battery
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -715,7 +719,7 @@ class FitbitBatteryLevelSensor(
|
||||
coordinator: FitbitDeviceCoordinator,
|
||||
user_profile_id: str,
|
||||
description: FitbitSensorEntityDescription,
|
||||
device: FitbitDevice,
|
||||
device: Device,
|
||||
) -> None:
|
||||
"""Initialize the Fitbit sensor."""
|
||||
super().__init__(coordinator)
|
||||
@@ -736,6 +740,6 @@ class FitbitBatteryLevelSensor(
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.device = self.coordinator.data[self.device.id]
|
||||
self.device = self.coordinator.data[cast(str, self.device.id)]
|
||||
self._attr_native_value = self.device.battery_level
|
||||
self.async_write_ha_state()
|
||||
|
||||
55
homeassistant/components/fressnapf_tracker/__init__.py
Normal file
55
homeassistant/components/fressnapf_tracker/__init__.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""The Fressnapf Tracker integration."""
|
||||
|
||||
from fressnapftracker import AuthClient
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import CONF_USER_ID
|
||||
from .coordinator import (
|
||||
FressnapfTrackerConfigEntry,
|
||||
FressnapfTrackerDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Fressnapf Tracker from a config entry."""
|
||||
auth_client = AuthClient(client=get_async_client(hass))
|
||||
devices = await auth_client.get_devices(
|
||||
user_id=entry.data[CONF_USER_ID],
|
||||
user_access_token=entry.data[CONF_ACCESS_TOKEN],
|
||||
)
|
||||
|
||||
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
|
||||
for device in devices:
|
||||
coordinator = FressnapfTrackerDataUpdateCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
device,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
coordinators.append(coordinator)
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
62
homeassistant/components/fressnapf_tracker/binary_sensor.py
Normal file
62
homeassistant/components/fressnapf_tracker/binary_sensor.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Binary Sensor platform for fressnapf_tracker."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fressnapftracker import Tracker
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FressnapfTrackerConfigEntry
|
||||
from .entity import FressnapfTrackerEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FressnapfTrackerBinarySensorDescription(BinarySensorEntityDescription):
|
||||
"""Class describing Fressnapf Tracker binary_sensor entities."""
|
||||
|
||||
value_fn: Callable[[Tracker], bool]
|
||||
|
||||
|
||||
BINARY_SENSOR_ENTITY_DESCRIPTIONS: tuple[
|
||||
FressnapfTrackerBinarySensorDescription, ...
|
||||
] = (
|
||||
FressnapfTrackerBinarySensorDescription(
|
||||
key="charging",
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.charging,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FressnapfTrackerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Fressnapf Tracker binary_sensors."""
|
||||
|
||||
async_add_entities(
|
||||
FressnapfTrackerBinarySensor(coordinator, sensor_description)
|
||||
for sensor_description in BINARY_SENSOR_ENTITY_DESCRIPTIONS
|
||||
for coordinator in entry.runtime_data
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerBinarySensor(FressnapfTrackerEntity, BinarySensorEntity):
|
||||
"""Fressnapf Tracker binary_sensor for general information."""
|
||||
|
||||
entity_description: FressnapfTrackerBinarySensorDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if the binary sensor is on."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
193
homeassistant/components/fressnapf_tracker/config_flow.py
Normal file
193
homeassistant/components/fressnapf_tracker/config_flow.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Config flow for the Fressnapf Tracker integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fressnapftracker import (
|
||||
AuthClient,
|
||||
FressnapfTrackerInvalidPhoneNumberError,
|
||||
FressnapfTrackerInvalidTokenError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import CONF_PHONE_NUMBER, CONF_SMS_CODE, CONF_USER_ID, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PHONE_NUMBER): str,
|
||||
}
|
||||
)
|
||||
STEP_SMS_CODE_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SMS_CODE): int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Fressnapf Tracker."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Init Config Flow."""
|
||||
self._context: dict[str, Any] = {}
|
||||
self._auth_client: AuthClient | None = None
|
||||
|
||||
@property
|
||||
def auth_client(self) -> AuthClient:
|
||||
"""Return the auth client, creating it if needed."""
|
||||
if self._auth_client is None:
|
||||
self._auth_client = AuthClient(client=get_async_client(self.hass))
|
||||
return self._auth_client
|
||||
|
||||
async def _async_request_sms_code(
|
||||
self, phone_number: str
|
||||
) -> tuple[dict[str, str], bool]:
|
||||
"""Request SMS code and return errors dict and success flag."""
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
response = await self.auth_client.request_sms_code(
|
||||
phone_number=phone_number
|
||||
)
|
||||
except FressnapfTrackerInvalidPhoneNumberError:
|
||||
errors["base"] = "invalid_phone_number"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
_LOGGER.debug("SMS code request response: %s", response)
|
||||
self._context[CONF_USER_ID] = response.id
|
||||
self._context[CONF_PHONE_NUMBER] = phone_number
|
||||
return errors, True
|
||||
return errors, False
|
||||
|
||||
async def _async_verify_sms_code(
|
||||
self, sms_code: int
|
||||
) -> tuple[dict[str, str], str | None]:
|
||||
"""Verify SMS code and return errors and access_token."""
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
verification_response = await self.auth_client.verify_phone_number(
|
||||
user_id=self._context[CONF_USER_ID],
|
||||
sms_code=sms_code,
|
||||
)
|
||||
except FressnapfTrackerInvalidTokenError:
|
||||
errors["base"] = "invalid_sms_code"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception during SMS code verification")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Phone number verification response: %s", verification_response
|
||||
)
|
||||
return errors, verification_response.user_token.access_token
|
||||
return errors, None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{CONF_PHONE_NUMBER: user_input[CONF_PHONE_NUMBER]}
|
||||
)
|
||||
errors, success = await self._async_request_sms_code(
|
||||
user_input[CONF_PHONE_NUMBER]
|
||||
)
|
||||
if success:
|
||||
await self.async_set_unique_id(str(self._context[CONF_USER_ID]))
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_sms_code()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_sms_code(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the SMS code step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
errors, access_token = await self._async_verify_sms_code(
|
||||
user_input[CONF_SMS_CODE]
|
||||
)
|
||||
if access_token:
|
||||
return self.async_create_entry(
|
||||
title=self._context[CONF_PHONE_NUMBER],
|
||||
data={
|
||||
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
|
||||
CONF_USER_ID: self._context[CONF_USER_ID],
|
||||
CONF_ACCESS_TOKEN: access_token,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="sms_code",
|
||||
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
errors, success = await self._async_request_sms_code(
|
||||
user_input[CONF_PHONE_NUMBER]
|
||||
)
|
||||
if success:
|
||||
if reconfigure_entry.data[CONF_USER_ID] != self._context[CONF_USER_ID]:
|
||||
errors["base"] = "account_change_not_allowed"
|
||||
else:
|
||||
return await self.async_step_reconfigure_sms_code()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_PHONE_NUMBER,
|
||||
default=reconfigure_entry.data.get(CONF_PHONE_NUMBER),
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure_sms_code(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the SMS code step during reconfiguration."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
errors, access_token = await self._async_verify_sms_code(
|
||||
user_input[CONF_SMS_CODE]
|
||||
)
|
||||
if access_token:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data={
|
||||
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
|
||||
CONF_USER_ID: self._context[CONF_USER_ID],
|
||||
CONF_ACCESS_TOKEN: access_token,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure_sms_code",
|
||||
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
6
homeassistant/components/fressnapf_tracker/const.py
Normal file
6
homeassistant/components/fressnapf_tracker/const.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Constants for the Fressnapf Tracker integration."""
|
||||
|
||||
DOMAIN = "fressnapf_tracker"
|
||||
CONF_PHONE_NUMBER = "phone_number"
|
||||
CONF_SMS_CODE = "sms_code"
|
||||
CONF_USER_ID = "user_id"
|
||||
50
homeassistant/components/fressnapf_tracker/coordinator.py
Normal file
50
homeassistant/components/fressnapf_tracker/coordinator.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Data update coordinator for Fressnapf Tracker integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from fressnapftracker import ApiClient, Device, FressnapfTrackerError, Tracker
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type FressnapfTrackerConfigEntry = ConfigEntry[
|
||||
list[FressnapfTrackerDataUpdateCoordinator]
|
||||
]
|
||||
|
||||
|
||||
class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
|
||||
"""Class to manage fetching data from the API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: FressnapfTrackerConfigEntry,
|
||||
device: Device,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=15),
|
||||
config_entry=config_entry,
|
||||
)
|
||||
self.device = device
|
||||
self.client = ApiClient(
|
||||
serial_number=device.serialnumber,
|
||||
device_token=device.token,
|
||||
client=get_async_client(hass),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> Tracker:
|
||||
try:
|
||||
return await self.client.get_tracker()
|
||||
except FressnapfTrackerError as exception:
|
||||
raise UpdateFailed(exception) from exception
|
||||
69
homeassistant/components/fressnapf_tracker/device_tracker.py
Normal file
69
homeassistant/components/fressnapf_tracker/device_tracker.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Device tracker platform for fressnapf_tracker."""
|
||||
|
||||
from homeassistant.components.device_tracker import SourceType
|
||||
from homeassistant.components.device_tracker.config_entry import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FressnapfTrackerConfigEntry, FressnapfTrackerDataUpdateCoordinator
|
||||
from .entity import FressnapfTrackerBaseEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FressnapfTrackerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the fressnapf_tracker device_trackers."""
|
||||
async_add_entities(
|
||||
FressnapfTrackerDeviceTracker(coordinator) for coordinator in entry.runtime_data
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerDeviceTracker(FressnapfTrackerBaseEntity, TrackerEntity):
|
||||
"""fressnapf_tracker device tracker."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_translation_key = "pet"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FressnapfTrackerDataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the device tracker."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = coordinator.device.serialnumber
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.coordinator.data.position is not None
|
||||
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return latitude value of the device."""
|
||||
if self.coordinator.data.position is not None:
|
||||
return self.coordinator.data.position.lat
|
||||
return None
|
||||
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return longitude value of the device."""
|
||||
if self.coordinator.data.position is not None:
|
||||
return self.coordinator.data.position.lng
|
||||
return None
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the source type, eg gps or router, of the device."""
|
||||
return SourceType.GPS
|
||||
|
||||
@property
|
||||
def location_accuracy(self) -> float:
|
||||
"""Return the location accuracy of the device.
|
||||
|
||||
Value in meters.
|
||||
"""
|
||||
if self.coordinator.data.position is not None:
|
||||
return float(self.coordinator.data.position.accuracy)
|
||||
return 0
|
||||
42
homeassistant/components/fressnapf_tracker/entity.py
Normal file
42
homeassistant/components/fressnapf_tracker/entity.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""fressnapf_tracker class."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import FressnapfTrackerDataUpdateCoordinator
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class FressnapfTrackerBaseEntity(
|
||||
CoordinatorEntity[FressnapfTrackerDataUpdateCoordinator]
|
||||
):
|
||||
"""Base entity for Fressnapf Tracker."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: FressnapfTrackerDataUpdateCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self.id = coordinator.device.serialnumber
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(self.id))},
|
||||
name=str(self.coordinator.data.name),
|
||||
model=str(self.coordinator.data.tracker_settings.generation),
|
||||
manufacturer="Fressnapf",
|
||||
serial_number=str(self.id),
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerEntity(FressnapfTrackerBaseEntity):
|
||||
"""Entity for fressnapf_tracker."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FressnapfTrackerDataUpdateCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{self.id}_{entity_description.key}"
|
||||
17
homeassistant/components/fressnapf_tracker/icons.json
Normal file
17
homeassistant/components/fressnapf_tracker/icons.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"entity": {
|
||||
"device_tracker": {
|
||||
"pet": {
|
||||
"default": "mdi:paw"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"energy_saving": {
|
||||
"default": "mdi:sleep",
|
||||
"state": {
|
||||
"off": "mdi:sleep-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
homeassistant/components/fressnapf_tracker/light.py
Normal file
93
homeassistant/components/fressnapf_tracker/light.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Light platform for fressnapf_tracker."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
LightEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FressnapfTrackerConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import FressnapfTrackerEntity
|
||||
|
||||
LIGHT_ENTITY_DESCRIPTION = LightEntityDescription(
|
||||
translation_key="led",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
key="led_brightness_value",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FressnapfTrackerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Fressnapf Tracker lights."""
|
||||
|
||||
async_add_entities(
|
||||
FressnapfTrackerLight(coordinator, LIGHT_ENTITY_DESCRIPTION)
|
||||
for coordinator in entry.runtime_data
|
||||
if coordinator.data.led_activatable is not None
|
||||
and coordinator.data.led_activatable.has_led
|
||||
and coordinator.data.tracker_settings.features.flash_light
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerLight(FressnapfTrackerEntity, LightEntity):
|
||||
"""Fressnapf Tracker light."""
|
||||
|
||||
_attr_color_mode: ColorMode = ColorMode.BRIGHTNESS
|
||||
_attr_supported_color_modes: set[ColorMode] = {ColorMode.BRIGHTNESS}
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
if TYPE_CHECKING:
|
||||
# The entity is not created if led_brightness_value is None
|
||||
assert self.coordinator.data.led_brightness_value is not None
|
||||
return int(round((self.coordinator.data.led_brightness_value / 100) * 255))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the device."""
|
||||
self.raise_if_not_activatable()
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
|
||||
brightness = int((brightness / 255) * 100)
|
||||
await self.coordinator.client.set_led_brightness(brightness)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the device."""
|
||||
await self.coordinator.client.set_led_brightness(0)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
def raise_if_not_activatable(self) -> None:
|
||||
"""Raise error with reasoning if light is not activatable."""
|
||||
if TYPE_CHECKING:
|
||||
# The entity is not created if led_activatable is None
|
||||
assert self.coordinator.data.led_activatable is not None
|
||||
error_type: str | None = None
|
||||
if not self.coordinator.data.led_activatable.seen_recently:
|
||||
error_type = "not_seen_recently"
|
||||
elif not self.coordinator.data.led_activatable.not_charging:
|
||||
error_type = "charging"
|
||||
elif not self.coordinator.data.led_activatable.nonempty_battery:
|
||||
error_type = "low_battery"
|
||||
if error_type is not None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=error_type,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if device is on."""
|
||||
if self.coordinator.data.led_brightness_value is not None:
|
||||
return self.coordinator.data.led_brightness_value > 0
|
||||
return False
|
||||
11
homeassistant/components/fressnapf_tracker/manifest.json
Normal file
11
homeassistant/components/fressnapf_tracker/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "fressnapf_tracker",
|
||||
"name": "Fressnapf Tracker",
|
||||
"codeowners": ["@eifinger"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fressnapf_tracker",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fressnapftracker==0.2.0"]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user