mirror of
https://github.com/home-assistant/core.git
synced 2025-11-18 23:40:09 +00:00
Compare commits
281 Commits
add_trigge
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8cbcc1977 | ||
|
|
4b69543515 | ||
|
|
97ef4a35b9 | ||
|
|
f782c78650 | ||
|
|
139ed34c74 | ||
|
|
7f14d013ac | ||
|
|
963e27dda4 | ||
|
|
b8e3d57fea | ||
|
|
0de2a16d0f | ||
|
|
c8c2413a09 | ||
|
|
291331f878 | ||
|
|
a13cdbdf3d | ||
|
|
1bf713f279 | ||
|
|
10c8ee417b | ||
|
|
b23134f4f1 | ||
|
|
f45a6f806b | ||
|
|
d3857a00d5 | ||
|
|
8c9b90a9f9 | ||
|
|
4eedc88935 | ||
|
|
343ea1b82d | ||
|
|
36e13653d2 | ||
|
|
80444b2165 | ||
|
|
262f06dd2b | ||
|
|
bd87119c2e | ||
|
|
0dfa037aa8 | ||
|
|
c32a471573 | ||
|
|
97b7e51171 | ||
|
|
433712b407 | ||
|
|
5d87e0f429 | ||
|
|
acb087f1e5 | ||
|
|
10c12623bf | ||
|
|
2fe20553b3 | ||
|
|
b431bb197a | ||
|
|
eb9d625926 | ||
|
|
3a69534b09 | ||
|
|
8f2cedcb73 | ||
|
|
3658953ff3 | ||
|
|
0be5893e37 | ||
|
|
c87e38c4cf | ||
|
|
4874610ad6 | ||
|
|
9180282fc6 | ||
|
|
118f30f32e | ||
|
|
bd10da126f | ||
|
|
b73a7928ca | ||
|
|
3e20c2ea93 | ||
|
|
60130d3d68 | ||
|
|
c45ede2e5d | ||
|
|
e167061f53 | ||
|
|
5560fb6c9e | ||
|
|
9808b6c961 | ||
|
|
e8cfde579e | ||
|
|
f695fb4d51 | ||
|
|
a0e0549d90 | ||
|
|
ba034c6c8c | ||
|
|
008bb85c59 | ||
|
|
cf1c1294d3 | ||
|
|
11d5d314cc | ||
|
|
6f0de3071a | ||
|
|
87d2597292 | ||
|
|
437bc04fe8 | ||
|
|
67a0d6a187 | ||
|
|
abb52bca81 | ||
|
|
d2d6889278 | ||
|
|
bdca592219 | ||
|
|
5c0c7b9ec3 | ||
|
|
9717599fb9 | ||
|
|
4d7de2f814 | ||
|
|
779590ce1c | ||
|
|
f3a185ff9c | ||
|
|
5a5a106984 | ||
|
|
796b421d99 | ||
|
|
0c03e8dbe9 | ||
|
|
47cf4e3ffe | ||
|
|
0ea0fc151d | ||
|
|
b7e5afec9f | ||
|
|
7a2bb67e82 | ||
|
|
e0612bec07 | ||
|
|
a06f4b6776 | ||
|
|
275670a526 | ||
|
|
d0d62526dd | ||
|
|
aefdf412b0 | ||
|
|
56ab6b2512 | ||
|
|
d1dea85cf5 | ||
|
|
84b0d39763 | ||
|
|
3aff225bc3 | ||
|
|
04458e01be | ||
|
|
ae51cfb8c0 | ||
|
|
c116a9c037 | ||
|
|
fb58758684 | ||
|
|
25fbcbc68c | ||
|
|
a670286b45 | ||
|
|
52ba55b17f | ||
|
|
ff0fc98c36 | ||
|
|
9f78a2263d | ||
|
|
9b4696a80b | ||
|
|
70fe8cae39 | ||
|
|
95eb45ab08 | ||
|
|
84f8e57141 | ||
|
|
f484b6df0d | ||
|
|
34c1d45ee0 | ||
|
|
09a105d9ad | ||
|
|
6bd1787d0a | ||
|
|
37040f5064 | ||
|
|
531397ec07 | ||
|
|
d6cc0f81de | ||
|
|
f8ef8a466a | ||
|
|
713015e26a | ||
|
|
f9c1e81c5e | ||
|
|
0549d113e6 | ||
|
|
0d842978ec | ||
|
|
55476ef6ea | ||
|
|
0e130d8fdd | ||
|
|
20bcb84956 | ||
|
|
bbb1d57081 | ||
|
|
121406569b | ||
|
|
4866c775ce | ||
|
|
7c5ab12270 | ||
|
|
099edfac20 | ||
|
|
aa31df0fd5 | ||
|
|
13fbeb6cdb | ||
|
|
8d557447df | ||
|
|
e6e3f2455f | ||
|
|
c9c518ee84 | ||
|
|
214731e964 | ||
|
|
c4b09c9a0a | ||
|
|
f5b5b2fb70 | ||
|
|
bb3cdd382b | ||
|
|
8d09b5c273 | ||
|
|
d92fa7fa72 | ||
|
|
0c45b7f615 | ||
|
|
bfa1116115 | ||
|
|
4984237987 | ||
|
|
3839573151 | ||
|
|
e02dc53df3 | ||
|
|
bedae1e12c | ||
|
|
b4eb73be98 | ||
|
|
0ac3f776fa | ||
|
|
8e8a4fff11 | ||
|
|
579ffcc64d | ||
|
|
81943fb31d | ||
|
|
70dd0bf12e | ||
|
|
c2d462c1e7 | ||
|
|
49e050cc60 | ||
|
|
f6d829a2f3 | ||
|
|
e44e3b6f25 | ||
|
|
af603661c0 | ||
|
|
35c6113777 | ||
|
|
3c2f729ddc | ||
|
|
0d63cb765f | ||
|
|
3cb414511b | ||
|
|
f55c36d42d | ||
|
|
26bb301cc0 | ||
|
|
4159e483ee | ||
|
|
7eb6f7cc07 | ||
|
|
a7d01b0b03 | ||
|
|
1e5cfddf83 | ||
|
|
006fc5b10a | ||
|
|
35a4b685b3 | ||
|
|
b166818ef4 | ||
|
|
34cd9f11d0 | ||
|
|
0711d62085 | ||
|
|
f70aeafb5f | ||
|
|
e2279b3589 | ||
|
|
87b68e99ec | ||
|
|
b6c8b787e8 | ||
|
|
78f26edc29 | ||
|
|
5e6a72de90 | ||
|
|
dcc559f8b6 | ||
|
|
eda49cced0 | ||
|
|
14e41ab119 | ||
|
|
46151456d8 | ||
|
|
39773a022a | ||
|
|
5f49a6450f | ||
|
|
dc8425c580 | ||
|
|
910bd371e4 | ||
|
|
802a225e11 | ||
|
|
84f66fa689 | ||
|
|
0b7e88d0e0 | ||
|
|
1fcaf95df5 | ||
|
|
6c7434531f | ||
|
|
5ec1c2b68b | ||
|
|
d8636d8346 | ||
|
|
434763c74d | ||
|
|
8cd2c1b43b | ||
|
|
44711787a4 | ||
|
|
98fd0ee683 | ||
|
|
303e4ce961 | ||
|
|
76f29298cd | ||
|
|
17f5d0a69f | ||
|
|
90561de438 | ||
|
|
aedd48c298 | ||
|
|
febbb85532 | ||
|
|
af67a35b75 | ||
|
|
dd34d458f5 | ||
|
|
603d4bcf87 | ||
|
|
2dadc1f2b3 | ||
|
|
936151fae5 | ||
|
|
9760eb7f2b | ||
|
|
7851bed00c | ||
|
|
6aba0b20c6 | ||
|
|
cadfed2348 | ||
|
|
44e2fa6996 | ||
|
|
d0ff617e17 | ||
|
|
8e499569a4 | ||
|
|
5e0ebddd6f | ||
|
|
c0f61f6c2b | ||
|
|
df60de38b0 | ||
|
|
cb086bb8e9 | ||
|
|
ee2e9dc7d6 | ||
|
|
85cd3c68b7 | ||
|
|
1b0b6e63f2 | ||
|
|
12fc79e8d3 | ||
|
|
ca2e7b9509 | ||
|
|
8e8becc43e | ||
|
|
dcec6c3dc8 | ||
|
|
c0e59c4508 | ||
|
|
cd379aadbf | ||
|
|
ccdd54b187 | ||
|
|
3f22dbaa2e | ||
|
|
c18dc0a9ab | ||
|
|
f0e4296d93 | ||
|
|
b3750109c6 | ||
|
|
93025c9845 | ||
|
|
df348644b1 | ||
|
|
8749b0d750 | ||
|
|
a6a1519c06 | ||
|
|
3068e19843 | ||
|
|
55feb1e735 | ||
|
|
bb7dc69131 | ||
|
|
aa9003a524 | ||
|
|
4e9da5249d | ||
|
|
f502739df2 | ||
|
|
0f2ff29378 | ||
|
|
2921e7ed3c | ||
|
|
25d44e8d37 | ||
|
|
0a480a26a3 | ||
|
|
d5da64dd8d | ||
|
|
92adcd8635 | ||
|
|
ee0c4b15c2 | ||
|
|
507f54198e | ||
|
|
0ed342b433 | ||
|
|
363c86faf3 | ||
|
|
095a7ad060 | ||
|
|
ab5981bbbd | ||
|
|
ac2fb53dfd | ||
|
|
02ff5de1ff | ||
|
|
5cd5d480d9 | ||
|
|
a3c7d772fc | ||
|
|
fe0c69dba7 | ||
|
|
e5365234c3 | ||
|
|
1531175bd3 | ||
|
|
62add59ff4 | ||
|
|
d8daca657b | ||
|
|
1891da46ea | ||
|
|
22ae894745 | ||
|
|
160810c69d | ||
|
|
2ae23b920a | ||
|
|
a7edfb082f | ||
|
|
3ac203b05f | ||
|
|
7c3eb19fc4 | ||
|
|
70c6fac743 | ||
|
|
e19d7250d5 | ||
|
|
a850d5dba7 | ||
|
|
0cf0f10654 | ||
|
|
8429f154ca | ||
|
|
7b4f5ad362 | ||
|
|
583b439557 | ||
|
|
05922de102 | ||
|
|
7675a44b90 | ||
|
|
1e4d645683 | ||
|
|
b5ae04605a | ||
|
|
2240d6b94c | ||
|
|
d1536ee636 | ||
|
|
8a926add7a | ||
|
|
31f769900a | ||
|
|
33ad777664 | ||
|
|
59a4e4a337 | ||
|
|
66a39933b0 | ||
|
|
ad395e3bba | ||
|
|
cfc6f2c229 | ||
|
|
63aa41c766 |
14
.github/workflows/builder.yml
vendored
14
.github/workflows/builder.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
publish: ${{ steps.version.outputs.publish }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
- arch: i386
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
- green
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Set build additional args
|
||||
run: |
|
||||
@@ -265,7 +265,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
@@ -418,7 +418,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
@@ -463,7 +463,7 @@ jobs:
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
|
||||
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 1
|
||||
CACHE_VERSION: 2
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.12"
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Generate partial Python venv restore key
|
||||
id: generate_python_cache_key
|
||||
run: |
|
||||
@@ -622,7 +622,7 @@ jobs:
|
||||
steps:
|
||||
- *checkout
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
|
||||
2
.github/workflows/wheels.yml
vendored
2
.github/workflows/wheels.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
|
||||
@@ -87,7 +87,7 @@ repos:
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(quality_scale)\.yaml|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
|
||||
- id: hassfest-metadata
|
||||
name: hassfest-metadata
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
|
||||
|
||||
@@ -231,6 +231,7 @@ homeassistant.components.google_cloud.*
|
||||
homeassistant.components.google_drive.*
|
||||
homeassistant.components.google_photos.*
|
||||
homeassistant.components.google_sheets.*
|
||||
homeassistant.components.google_weather.*
|
||||
homeassistant.components.govee_ble.*
|
||||
homeassistant.components.gpsd.*
|
||||
homeassistant.components.greeneye_monitor.*
|
||||
@@ -578,6 +579,7 @@ homeassistant.components.wiz.*
|
||||
homeassistant.components.wled.*
|
||||
homeassistant.components.workday.*
|
||||
homeassistant.components.worldclock.*
|
||||
homeassistant.components.xbox.*
|
||||
homeassistant.components.xiaomi_ble.*
|
||||
homeassistant.components.yale_smart_alarm.*
|
||||
homeassistant.components.yalexs_ble.*
|
||||
|
||||
10
CODEOWNERS
generated
10
CODEOWNERS
generated
@@ -607,6 +607,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/google_tasks/ @allenporter
|
||||
/homeassistant/components/google_travel_time/ @eifinger
|
||||
/tests/components/google_travel_time/ @eifinger
|
||||
/homeassistant/components/google_weather/ @tronikos
|
||||
/tests/components/google_weather/ @tronikos
|
||||
/homeassistant/components/govee_ble/ @bdraco
|
||||
/tests/components/govee_ble/ @bdraco
|
||||
/homeassistant/components/govee_light_local/ @Galorhallen
|
||||
@@ -1017,8 +1019,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/msteams/ @peroyvind
|
||||
/homeassistant/components/mullvad/ @meichthys
|
||||
/tests/components/mullvad/ @meichthys
|
||||
/homeassistant/components/music_assistant/ @music-assistant
|
||||
/tests/components/music_assistant/ @music-assistant
|
||||
/homeassistant/components/music_assistant/ @music-assistant @arturpragacz
|
||||
/tests/components/music_assistant/ @music-assistant @arturpragacz
|
||||
/homeassistant/components/mutesync/ @currentoor
|
||||
/tests/components/mutesync/ @currentoor
|
||||
/homeassistant/components/my/ @home-assistant/core
|
||||
@@ -1374,6 +1376,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/sanix/ @tomaszsluszniak
|
||||
/homeassistant/components/satel_integra/ @Tommatheussen
|
||||
/tests/components/satel_integra/ @Tommatheussen
|
||||
/homeassistant/components/saunum/ @mettolen
|
||||
/tests/components/saunum/ @mettolen
|
||||
/homeassistant/components/scene/ @home-assistant/core
|
||||
/tests/components/scene/ @home-assistant/core
|
||||
/homeassistant/components/schedule/ @home-assistant/core
|
||||
@@ -1732,6 +1736,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
|
||||
/homeassistant/components/vicare/ @CFenner
|
||||
/tests/components/vicare/ @CFenner
|
||||
/homeassistant/components/victron_ble/ @rajlaud
|
||||
/tests/components/victron_ble/ @rajlaud
|
||||
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
|
||||
/tests/components/victron_remote_monitoring/ @AndyTempel
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
|
||||
10
build.yaml
10
build.yaml
@@ -1,10 +1,10 @@
|
||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.11.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.11.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.11.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.11.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.11.0
|
||||
cosign:
|
||||
base_identity: https://github.com/home-assistant/docker/.*
|
||||
identity: https://github.com/home-assistant/core/.*
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"google_tasks",
|
||||
"google_translate",
|
||||
"google_travel_time",
|
||||
"google_weather",
|
||||
"google_wifi",
|
||||
"google",
|
||||
"nest",
|
||||
|
||||
5
homeassistant/brands/victron.json
Normal file
5
homeassistant/brands/victron.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "victron",
|
||||
"name": "Victron",
|
||||
"integrations": ["victron_ble", "victron_remote_monitoring"]
|
||||
}
|
||||
@@ -45,7 +45,7 @@ SERVICE_REFRESH_SCHEMA = vol.Schema(
|
||||
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
|
||||
)
|
||||
|
||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
|
||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
|
||||
type AdGuardConfigEntry = ConfigEntry[AdGuardData]
|
||||
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["adguardhome"],
|
||||
"requirements": ["adguardhome==0.7.0"]
|
||||
"requirements": ["adguardhome==0.8.1"]
|
||||
}
|
||||
|
||||
71
homeassistant/components/adguard/update.py
Normal file
71
homeassistant/components/adguard/update.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""AdGuard Home Update platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from adguardhome import AdGuardHomeError
|
||||
|
||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdGuardConfigEntry, AdGuardData
|
||||
from .const import DOMAIN
|
||||
from .entity import AdGuardHomeEntity
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=300)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AdGuardConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AdGuard Home update entity based on a config entry."""
|
||||
data = entry.runtime_data
|
||||
|
||||
if (await data.client.update.update_available()).disabled:
|
||||
return
|
||||
|
||||
async_add_entities([AdGuardHomeUpdate(data, entry)], True)
|
||||
|
||||
|
||||
class AdGuardHomeUpdate(AdGuardHomeEntity, UpdateEntity):
|
||||
"""Defines an AdGuard Home update."""
|
||||
|
||||
_attr_supported_features = UpdateEntityFeature.INSTALL
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: AdGuardData,
|
||||
entry: AdGuardConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize AdGuard Home update."""
|
||||
super().__init__(data, entry)
|
||||
|
||||
self._attr_unique_id = "_".join(
|
||||
[DOMAIN, self.adguard.host, str(self.adguard.port), "update"]
|
||||
)
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
value = await self.adguard.update.update_available()
|
||||
self._attr_installed_version = self.data.version
|
||||
self._attr_latest_version = value.new_version
|
||||
self._attr_release_summary = value.announcement
|
||||
self._attr_release_url = value.announcement_url
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install latest update."""
|
||||
try:
|
||||
await self.adguard.update.begin_update()
|
||||
except AdGuardHomeError as err:
|
||||
raise HomeAssistantError(f"Failed to install update: {err}") from err
|
||||
self.hass.config_entries.async_schedule_reload(self._entry.entry_id)
|
||||
@@ -16,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
|
||||
@@ -43,6 +44,9 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
name=entry.title,
|
||||
config_entry=entry,
|
||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||
request_refresh_debouncer=Debouncer(
|
||||
hass, _LOGGER, cooldown=30, immediate=False
|
||||
),
|
||||
)
|
||||
self.api = AmazonEchoApi(
|
||||
session,
|
||||
|
||||
@@ -6,9 +6,8 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HassJob, HomeAssistant, callback
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
@@ -20,7 +19,7 @@ from .analytics import (
|
||||
EntityAnalyticsModifications,
|
||||
async_devices_payload,
|
||||
)
|
||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
|
||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, PREFERENCE_SCHEMA
|
||||
from .http import AnalyticsDevicesView
|
||||
|
||||
__all__ = [
|
||||
@@ -43,28 +42,9 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
|
||||
@callback
|
||||
def start_schedule(_event: Event) -> None:
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
# Wait 15 min after started
|
||||
async_call_later(
|
||||
hass,
|
||||
900,
|
||||
HassJob(
|
||||
analytics.send_analytics,
|
||||
name="analytics schedule",
|
||||
cancel_on_shutdown=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Send every day
|
||||
async_track_time_interval(
|
||||
hass,
|
||||
analytics.send_analytics,
|
||||
INTERVAL,
|
||||
name="analytics daily",
|
||||
cancel_on_shutdown=True,
|
||||
)
|
||||
await analytics.async_schedule()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
@@ -111,7 +91,7 @@ async def websocket_analytics_preferences(
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
|
||||
await analytics.save_preferences(preferences)
|
||||
await analytics.send_analytics()
|
||||
await analytics.async_schedule()
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
|
||||
@@ -7,6 +7,8 @@ from asyncio import timeout
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
||||
from dataclasses import asdict as dataclass_asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Protocol
|
||||
import uuid
|
||||
|
||||
@@ -31,10 +33,18 @@ from homeassistant.const import (
|
||||
BASE_PLATFORMS,
|
||||
__version__ as HA_VERSION,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
HassJob,
|
||||
HomeAssistant,
|
||||
ReleaseChannel,
|
||||
callback,
|
||||
get_release_channel,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.storage import Store
|
||||
@@ -51,6 +61,7 @@ from homeassistant.setup import async_get_loaded_integrations
|
||||
from .const import (
|
||||
ANALYTICS_ENDPOINT_URL,
|
||||
ANALYTICS_ENDPOINT_URL_DEV,
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
ATTR_ADDON_COUNT,
|
||||
ATTR_ADDONS,
|
||||
ATTR_ARCH,
|
||||
@@ -71,6 +82,7 @@ from .const import (
|
||||
ATTR_PROTECTED,
|
||||
ATTR_RECORDER,
|
||||
ATTR_SLUG,
|
||||
ATTR_SNAPSHOTS,
|
||||
ATTR_STATE_COUNT,
|
||||
ATTR_STATISTICS,
|
||||
ATTR_SUPERVISOR,
|
||||
@@ -80,8 +92,10 @@ from .const import (
|
||||
ATTR_UUID,
|
||||
ATTR_VERSION,
|
||||
DOMAIN,
|
||||
INTERVAL,
|
||||
LOGGER,
|
||||
PREFERENCE_SCHEMA,
|
||||
SNAPSHOT_VERSION,
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
)
|
||||
@@ -194,13 +208,18 @@ def gen_uuid() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
RELEASE_CHANNEL = get_release_channel()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyticsData:
|
||||
"""Analytics data."""
|
||||
|
||||
onboarded: bool
|
||||
preferences: dict[str, bool]
|
||||
uuid: str | None
|
||||
uuid: str | None = None
|
||||
submission_identifier: str | None = None
|
||||
snapshot_submission_time: float | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> AnalyticsData:
|
||||
@@ -209,6 +228,8 @@ class AnalyticsData:
|
||||
data["onboarded"],
|
||||
data["preferences"],
|
||||
data["uuid"],
|
||||
data.get("submission_identifier"),
|
||||
data.get("snapshot_submission_time"),
|
||||
)
|
||||
|
||||
|
||||
@@ -219,8 +240,10 @@ class Analytics:
|
||||
"""Initialize the Analytics class."""
|
||||
self.hass: HomeAssistant = hass
|
||||
self.session = async_get_clientsession(hass)
|
||||
self._data = AnalyticsData(False, {}, None)
|
||||
self._data = AnalyticsData(False, {})
|
||||
self._store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
self._basic_scheduled: CALLBACK_TYPE | None = None
|
||||
self._snapshot_scheduled: CALLBACK_TYPE | None = None
|
||||
|
||||
@property
|
||||
def preferences(self) -> dict:
|
||||
@@ -228,6 +251,7 @@ class Analytics:
|
||||
preferences = self._data.preferences
|
||||
return {
|
||||
ATTR_BASE: preferences.get(ATTR_BASE, False),
|
||||
ATTR_SNAPSHOTS: preferences.get(ATTR_SNAPSHOTS, False),
|
||||
ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False),
|
||||
ATTR_USAGE: preferences.get(ATTR_USAGE, False),
|
||||
ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False),
|
||||
@@ -244,9 +268,9 @@ class Analytics:
|
||||
return self._data.uuid
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
def endpoint_basic(self) -> str:
|
||||
"""Return the endpoint that will receive the payload."""
|
||||
if HA_VERSION.endswith("0.dev0"):
|
||||
if RELEASE_CHANNEL is ReleaseChannel.DEV:
|
||||
# dev installations will contact the dev analytics environment
|
||||
return ANALYTICS_ENDPOINT_URL_DEV
|
||||
return ANALYTICS_ENDPOINT_URL
|
||||
@@ -277,13 +301,17 @@ class Analytics:
|
||||
):
|
||||
self._data.preferences[ATTR_DIAGNOSTICS] = False
|
||||
|
||||
async def _save(self) -> None:
|
||||
"""Save data."""
|
||||
await self._store.async_save(dataclass_asdict(self._data))
|
||||
|
||||
async def save_preferences(self, preferences: dict) -> None:
|
||||
"""Save preferences."""
|
||||
preferences = PREFERENCE_SCHEMA(preferences)
|
||||
self._data.preferences.update(preferences)
|
||||
self._data.onboarded = True
|
||||
|
||||
await self._store.async_save(dataclass_asdict(self._data))
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
await hassio.async_update_diagnostics(
|
||||
@@ -292,17 +320,16 @@ class Analytics:
|
||||
|
||||
async def send_analytics(self, _: datetime | None = None) -> None:
|
||||
"""Send analytics."""
|
||||
if not self.onboarded or not self.preferences.get(ATTR_BASE, False):
|
||||
return
|
||||
|
||||
hass = self.hass
|
||||
supervisor_info = None
|
||||
operating_system_info: dict[str, Any] = {}
|
||||
|
||||
if not self.onboarded or not self.preferences.get(ATTR_BASE, False):
|
||||
LOGGER.debug("Nothing to submit")
|
||||
return
|
||||
|
||||
if self._data.uuid is None:
|
||||
self._data.uuid = gen_uuid()
|
||||
await self._store.async_save(dataclass_asdict(self._data))
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
@@ -436,7 +463,7 @@ class Analytics:
|
||||
|
||||
try:
|
||||
async with timeout(30):
|
||||
response = await self.session.post(self.endpoint, json=payload)
|
||||
response = await self.session.post(self.endpoint_basic, json=payload)
|
||||
if response.status == 200:
|
||||
LOGGER.info(
|
||||
(
|
||||
@@ -449,7 +476,7 @@ class Analytics:
|
||||
LOGGER.warning(
|
||||
"Sending analytics failed with statuscode %s from %s",
|
||||
response.status,
|
||||
self.endpoint,
|
||||
self.endpoint_basic,
|
||||
)
|
||||
except TimeoutError:
|
||||
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL)
|
||||
@@ -489,6 +516,182 @@ class Analytics:
|
||||
if entry.source != SOURCE_IGNORE and entry.disabled_by is None
|
||||
)
|
||||
|
||||
async def send_snapshot(self, _: datetime | None = None) -> None:
|
||||
"""Send a snapshot."""
|
||||
if not self.onboarded or not self.preferences.get(ATTR_SNAPSHOTS, False):
|
||||
return
|
||||
|
||||
payload = await _async_snapshot_payload(self.hass)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"home-assistant/{HA_VERSION}",
|
||||
}
|
||||
if self._data.submission_identifier is not None:
|
||||
headers["X-Device-Database-Submission-Identifier"] = (
|
||||
self._data.submission_identifier
|
||||
)
|
||||
|
||||
try:
|
||||
async with timeout(30):
|
||||
response = await self.session.post(
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL, json=payload, headers=headers
|
||||
)
|
||||
|
||||
if response.status == 200: # OK
|
||||
response_data = await response.json()
|
||||
new_identifier = response_data.get("submission_identifier")
|
||||
|
||||
if (
|
||||
new_identifier is not None
|
||||
and new_identifier != self._data.submission_identifier
|
||||
):
|
||||
self._data.submission_identifier = new_identifier
|
||||
await self._save()
|
||||
|
||||
LOGGER.info(
|
||||
"Submitted snapshot analytics to Home Assistant servers"
|
||||
)
|
||||
|
||||
elif response.status == 400: # Bad Request
|
||||
response_data = await response.json()
|
||||
error_kind = response_data.get("kind", "unknown")
|
||||
error_message = response_data.get("message", "Unknown error")
|
||||
|
||||
if error_kind == "invalid-submission-identifier":
|
||||
# Clear the invalid identifier and retry on next cycle
|
||||
LOGGER.warning(
|
||||
"Invalid submission identifier to %s, clearing: %s",
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
error_message,
|
||||
)
|
||||
self._data.submission_identifier = None
|
||||
await self._save()
|
||||
else:
|
||||
LOGGER.warning(
|
||||
"Malformed snapshot analytics submission (%s) to %s: %s",
|
||||
error_kind,
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
error_message,
|
||||
)
|
||||
|
||||
elif response.status == 503: # Service Unavailable
|
||||
response_text = await response.text()
|
||||
LOGGER.warning(
|
||||
"Snapshot analytics service %s unavailable: %s",
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
response_text,
|
||||
)
|
||||
|
||||
else:
|
||||
LOGGER.warning(
|
||||
"Unexpected status code %s when submitting snapshot analytics to %s",
|
||||
response.status,
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
)
|
||||
|
||||
except TimeoutError:
|
||||
LOGGER.error(
|
||||
"Timeout sending snapshot analytics to %s",
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
LOGGER.error(
|
||||
"Error sending snapshot analytics to %s: %r",
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
err,
|
||||
)
|
||||
|
||||
async def async_schedule(self) -> None:
|
||||
"""Schedule analytics."""
|
||||
if not self.onboarded:
|
||||
LOGGER.debug("Analytics not scheduled")
|
||||
if self._basic_scheduled is not None:
|
||||
self._basic_scheduled()
|
||||
self._basic_scheduled = None
|
||||
if self._snapshot_scheduled:
|
||||
self._snapshot_scheduled()
|
||||
self._snapshot_scheduled = None
|
||||
return
|
||||
|
||||
if not self.preferences.get(ATTR_BASE, False):
|
||||
LOGGER.debug("Basic analytics not scheduled")
|
||||
if self._basic_scheduled is not None:
|
||||
self._basic_scheduled()
|
||||
self._basic_scheduled = None
|
||||
elif self._basic_scheduled is None:
|
||||
# Wait 15 min after started for basic analytics
|
||||
self._basic_scheduled = async_call_later(
|
||||
self.hass,
|
||||
900,
|
||||
HassJob(
|
||||
self._async_schedule_basic,
|
||||
name="basic analytics schedule",
|
||||
cancel_on_shutdown=True,
|
||||
),
|
||||
)
|
||||
|
||||
if not self.preferences.get(ATTR_SNAPSHOTS, False) or RELEASE_CHANNEL not in (
|
||||
ReleaseChannel.DEV,
|
||||
ReleaseChannel.NIGHTLY,
|
||||
):
|
||||
LOGGER.debug("Snapshot analytics not scheduled")
|
||||
if self._snapshot_scheduled:
|
||||
self._snapshot_scheduled()
|
||||
self._snapshot_scheduled = None
|
||||
elif self._snapshot_scheduled is None:
|
||||
snapshot_submission_time = self._data.snapshot_submission_time
|
||||
|
||||
if snapshot_submission_time is None:
|
||||
# Randomize the submission time within the 24 hours
|
||||
snapshot_submission_time = random.uniform(0, 86400)
|
||||
self._data.snapshot_submission_time = snapshot_submission_time
|
||||
await self._save()
|
||||
LOGGER.debug(
|
||||
"Initialized snapshot submission time to %s",
|
||||
snapshot_submission_time,
|
||||
)
|
||||
|
||||
# Calculate delay until next submission
|
||||
current_time = time.time()
|
||||
delay = (snapshot_submission_time - current_time) % 86400
|
||||
|
||||
self._snapshot_scheduled = async_call_later(
|
||||
self.hass,
|
||||
delay,
|
||||
HassJob(
|
||||
self._async_schedule_snapshots,
|
||||
name="snapshot analytics schedule",
|
||||
cancel_on_shutdown=True,
|
||||
),
|
||||
)
|
||||
|
||||
async def _async_schedule_basic(self, _: datetime | None = None) -> None:
|
||||
"""Schedule basic analytics."""
|
||||
await self.send_analytics()
|
||||
|
||||
# Send basic analytics every day
|
||||
self._basic_scheduled = async_track_time_interval(
|
||||
self.hass,
|
||||
self.send_analytics,
|
||||
INTERVAL,
|
||||
name="basic analytics daily",
|
||||
cancel_on_shutdown=True,
|
||||
)
|
||||
|
||||
async def _async_schedule_snapshots(self, _: datetime | None = None) -> None:
|
||||
"""Schedule snapshot analytics."""
|
||||
await self.send_snapshot()
|
||||
|
||||
# Send snapshot analytics every day
|
||||
self._snapshot_scheduled = async_track_time_interval(
|
||||
self.hass,
|
||||
self.send_snapshot,
|
||||
INTERVAL,
|
||||
name="snapshot analytics daily",
|
||||
cancel_on_shutdown=True,
|
||||
)
|
||||
|
||||
|
||||
def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
|
||||
"""Extract domains from the YAML configuration."""
|
||||
@@ -505,8 +708,8 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
|
||||
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
|
||||
|
||||
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
"""Return detailed information about entities and devices."""
|
||||
async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
"""Return detailed information about entities and devices for a snapshot."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
@@ -711,8 +914,13 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
|
||||
entities_info.append(entity_info)
|
||||
|
||||
return integrations_info
|
||||
|
||||
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
"""Return detailed information about entities and devices for a direct download."""
|
||||
return {
|
||||
"version": "home-assistant:1",
|
||||
"version": f"home-assistant:{SNAPSHOT_VERSION}",
|
||||
"home_assistant": HA_VERSION,
|
||||
"integrations": integrations_info,
|
||||
"integrations": await _async_snapshot_payload(hass),
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import voluptuous as vol
|
||||
|
||||
ANALYTICS_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1"
|
||||
ANALYTICS_ENDPOINT_URL_DEV = "https://analytics-api-dev.home-assistant.io/v1"
|
||||
SNAPSHOT_VERSION = "1"
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL = f"https://device-database.eco-dev-aws.openhomefoundation.com/api/v1/snapshot/{SNAPSHOT_VERSION}"
|
||||
DOMAIN = "analytics"
|
||||
INTERVAL = timedelta(days=1)
|
||||
STORAGE_KEY = "core.analytics"
|
||||
@@ -38,6 +40,7 @@ ATTR_PREFERENCES = "preferences"
|
||||
ATTR_PROTECTED = "protected"
|
||||
ATTR_RECORDER = "recorder"
|
||||
ATTR_SLUG = "slug"
|
||||
ATTR_SNAPSHOTS = "snapshots"
|
||||
ATTR_STATE_COUNT = "state_count"
|
||||
ATTR_STATISTICS = "statistics"
|
||||
ATTR_SUPERVISOR = "supervisor"
|
||||
@@ -51,6 +54,7 @@ ATTR_VERSION = "version"
|
||||
PREFERENCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_BASE): bool,
|
||||
vol.Optional(ATTR_SNAPSHOTS): bool,
|
||||
vol.Optional(ATTR_DIAGNOSTICS): bool,
|
||||
vol.Optional(ATTR_STATISTICS): bool,
|
||||
vol.Optional(ATTR_USAGE): bool,
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from functools import partial
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
@@ -283,7 +284,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
vol.Optional(
|
||||
CONF_CHAT_MODEL,
|
||||
default=RECOMMENDED_CHAT_MODEL,
|
||||
): str,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=await self._get_model_list(), custom_value=True
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_MAX_TOKENS,
|
||||
default=RECOMMENDED_MAX_TOKENS,
|
||||
@@ -394,6 +399,39 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
async def _get_model_list(self) -> list[SelectOptionDict]:
|
||||
"""Get list of available models."""
|
||||
try:
|
||||
client = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
anthropic.AsyncAnthropic,
|
||||
api_key=self._get_entry().data[CONF_API_KEY],
|
||||
)
|
||||
)
|
||||
models = (await client.models.list()).data
|
||||
except anthropic.AnthropicError:
|
||||
models = []
|
||||
_LOGGER.debug("Available models: %s", models)
|
||||
model_options: list[SelectOptionDict] = []
|
||||
short_form = re.compile(r"[^\d]-\d$")
|
||||
for model_info in models:
|
||||
# Resolve alias from versioned model name:
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id
|
||||
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
|
||||
else model_info.id
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias,
|
||||
)
|
||||
)
|
||||
return model_options
|
||||
|
||||
async def _get_location_data(self) -> dict[str, str]:
|
||||
"""Get approximate location data of the user."""
|
||||
location_data: dict[str, str] = {}
|
||||
|
||||
@@ -392,7 +392,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
input={},
|
||||
)
|
||||
current_tool_args = ""
|
||||
if response.content_block.name == output_tool:
|
||||
@@ -459,7 +459,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="server_tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
input={},
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(response.content_block, WebSearchToolResultBlock):
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.69.0"]
|
||||
"requirements": ["anthropic==0.73.0"]
|
||||
}
|
||||
|
||||
@@ -7,3 +7,26 @@ CONNECTION_TIMEOUT: int = 10
|
||||
|
||||
# Field name of last self test retrieved from apcupsd.
|
||||
LAST_S_TEST: Final = "laststest"
|
||||
|
||||
# Mapping of deprecated sensor keys (as reported by apcupsd, lower-cased) to their deprecation
|
||||
# repair issue translation keys.
|
||||
DEPRECATED_SENSORS: Final = {
|
||||
"apc": "apc_deprecated",
|
||||
"end apc": "date_deprecated",
|
||||
"date": "date_deprecated",
|
||||
"apcmodel": "available_via_device_info",
|
||||
"model": "available_via_device_info",
|
||||
"firmware": "available_via_device_info",
|
||||
"version": "available_via_device_info",
|
||||
"upsname": "available_via_device_info",
|
||||
"serialno": "available_via_device_info",
|
||||
}
|
||||
|
||||
AVAILABLE_VIA_DEVICE_ATTR: Final = {
|
||||
"apcmodel": "model",
|
||||
"model": "model",
|
||||
"firmware": "hw_version",
|
||||
"version": "sw_version",
|
||||
"upsname": "name",
|
||||
"serialno": "serial_number",
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -22,9 +24,11 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
import homeassistant.helpers.issue_registry as ir
|
||||
|
||||
from .const import LAST_S_TEST
|
||||
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST
|
||||
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
|
||||
from .entity import APCUPSdEntity
|
||||
|
||||
@@ -528,3 +532,62 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
|
||||
if not self.native_unit_of_measurement:
|
||||
self._attr_native_unit_of_measurement = inferred_unit
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle when entity is added to Home Assistant.
|
||||
|
||||
If this is a deprecated sensor entity, create a repair issue to guide
|
||||
the user to disable it.
|
||||
"""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
reason = DEPRECATED_SENSORS.get(self.entity_description.key)
|
||||
if not reason:
|
||||
return
|
||||
|
||||
automations = automations_with_entity(self.hass, self.entity_id)
|
||||
scripts = scripts_with_entity(self.hass, self.entity_id)
|
||||
if not automations and not scripts:
|
||||
return
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
items = [
|
||||
f"- [{entry.name or entry.original_name or entity_id}]"
|
||||
f"(/config/{integration}/edit/{entry.unique_id or entity_id.split('.', 1)[-1]})"
|
||||
for integration, entities in (
|
||||
("automation", automations),
|
||||
("script", scripts),
|
||||
)
|
||||
for entity_id in entities
|
||||
if (entry := entity_registry.async_get(entity_id))
|
||||
]
|
||||
placeholders = {
|
||||
"entity_name": str(self.name or self.entity_id),
|
||||
"entity_id": self.entity_id,
|
||||
"items": "\n".join(items),
|
||||
}
|
||||
if via_attr := AVAILABLE_VIA_DEVICE_ATTR.get(self.entity_description.key):
|
||||
placeholders["available_via_device_attr"] = via_attr
|
||||
if device_entry := self.device_entry:
|
||||
placeholders["device_id"] = device_entry.id
|
||||
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"{reason}_{self.entity_id}",
|
||||
breaks_in_ha_version="2026.6.0",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=reason,
|
||||
translation_placeholders=placeholders,
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Handle when entity will be removed from Home Assistant."""
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
if issue_key := DEPRECATED_SENSORS.get(self.entity_description.key):
|
||||
ir.async_delete_issue(self.hass, DOMAIN, f"{issue_key}_{self.entity_id}")
|
||||
|
||||
@@ -241,5 +241,19 @@
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to APC UPS Daemon."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"apc_deprecated": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because it exposes internal details of the APC UPS Daemon response.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use supported APC UPS entities instead. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
},
|
||||
"available_via_device_info": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the same value is available from the device registry via `device_attr(\"{device_id}\", \"{available_via_device_attr}\")`.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use the `device_attr` helper instead of this sensor. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
},
|
||||
"date_deprecated": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the timestamp is already available from other APC UPS sensors via their last updated time.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to reference any entity's `last_updated` attribute instead (for example, `states.binary_sensor.apcups_online_status.last_updated`). Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,8 +111,6 @@ def handle_errors_and_zip[_AsusWrtBridgeT: AsusWrtBridge](
|
||||
|
||||
if isinstance(data, dict):
|
||||
return dict(zip(keys, list(data.values()), strict=False))
|
||||
if not isinstance(data, (list, tuple)):
|
||||
raise UpdateFailed("Received invalid data type")
|
||||
return dict(zip(keys, data, strict=False))
|
||||
|
||||
return _wrapper
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==2.45.0",
|
||||
"dbus-fast==3.0.0",
|
||||
"habluetooth==5.7.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -74,8 +74,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
||||
super().__init__(data.fast_coordinator, data)
|
||||
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
|
||||
|
||||
self._attr_min_temp = data.static.min_temp.value
|
||||
self._attr_max_temp = data.static.max_temp.value
|
||||
# Set temperature range if available, otherwise use Home Assistant defaults
|
||||
if data.static.min_temp is not None and data.static.min_temp.value is not None:
|
||||
self._attr_min_temp = data.static.min_temp.value
|
||||
if data.static.max_temp is not None and data.static.max_temp.value is not None:
|
||||
self._attr_max_temp = data.static.max_temp.value
|
||||
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
|
||||
|
||||
@property
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"requirements": ["python-bsblan==3.1.0"],
|
||||
"requirements": ["python-bsblan==3.1.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -55,6 +55,7 @@ from .const import (
|
||||
CONF_ALIASES,
|
||||
CONF_API_SERVER,
|
||||
CONF_COGNITO_CLIENT_ID,
|
||||
CONF_DISCOVERY_SERVICE_ACTIONS,
|
||||
CONF_ENTITY_CONFIG,
|
||||
CONF_FILTER,
|
||||
CONF_GOOGLE_ACTIONS,
|
||||
@@ -139,6 +140,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_MODE): vol.In([MODE_DEV]),
|
||||
vol.Required(CONF_API_SERVER): str,
|
||||
vol.Optional(CONF_DISCOVERY_SERVICE_ACTIONS): {str: cv.url},
|
||||
}
|
||||
),
|
||||
_BASE_CONFIG_SCHEMA.extend(
|
||||
|
||||
@@ -79,6 +79,7 @@ CONF_ACCOUNT_LINK_SERVER = "account_link_server"
|
||||
CONF_ACCOUNTS_SERVER = "accounts_server"
|
||||
CONF_ACME_SERVER = "acme_server"
|
||||
CONF_API_SERVER = "api_server"
|
||||
CONF_DISCOVERY_SERVICE_ACTIONS = "discovery_service_actions"
|
||||
CONF_RELAYER_SERVER = "relayer_server"
|
||||
CONF_REMOTESTATE_SERVER = "remotestate_server"
|
||||
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"
|
||||
|
||||
@@ -37,13 +37,6 @@ USER_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
|
||||
STEP_RECONFIGURE = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
||||
@@ -175,36 +168,55 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the device."""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
if not user_input:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure", data_schema=STEP_RECONFIGURE
|
||||
)
|
||||
|
||||
updated_host = user_input[CONF_HOST]
|
||||
|
||||
self._async_abort_entries_match({CONF_HOST: updated_host})
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except InvalidPin:
|
||||
errors["base"] = "invalid_pin"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry, data_updates={CONF_HOST: updated_host}
|
||||
)
|
||||
if user_input is not None:
|
||||
updated_host = user_input[CONF_HOST]
|
||||
|
||||
self._async_abort_entries_match({CONF_HOST: updated_host})
|
||||
|
||||
try:
|
||||
data_to_validate = {
|
||||
CONF_HOST: updated_host,
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_PIN: user_input[CONF_PIN],
|
||||
CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
|
||||
}
|
||||
await validate_input(self.hass, data_to_validate)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except InvalidPin:
|
||||
errors["base"] = "invalid_pin"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
data_updates = {
|
||||
CONF_HOST: updated_host,
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_PIN: user_input[CONF_PIN],
|
||||
}
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry, data_updates=data_updates
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_HOST, default=reconfigure_entry.data[CONF_HOST]
|
||||
): cv.string,
|
||||
vol.Required(
|
||||
CONF_PORT, default=reconfigure_entry.data[CONF_PORT]
|
||||
): cv.port,
|
||||
vol.Optional(CONF_PIN): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=STEP_RECONFIGURE,
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from hassil.recognize import RecognizeResult
|
||||
import voluptuous as vol
|
||||
@@ -21,6 +21,7 @@ from homeassistant.core import (
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, intent
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.reload import async_integration_yaml_config
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
|
||||
@@ -52,6 +53,8 @@ from .const import (
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
HOME_ASSISTANT_AGENT,
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
SERVICE_PROCESS,
|
||||
SERVICE_RELOAD,
|
||||
ConversationEntityFeature,
|
||||
@@ -266,10 +269,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
|
||||
hass.data[DATA_COMPONENT] = entity_component
|
||||
|
||||
agent_config = config.get(DOMAIN, {})
|
||||
await async_setup_default_agent(
|
||||
hass, entity_component, config_intents=agent_config.get("intents", {})
|
||||
)
|
||||
manager = get_agent_manager(hass)
|
||||
|
||||
hass_config_path = hass.config.path()
|
||||
config_intents = _get_config_intents(config, hass_config_path)
|
||||
manager.update_config_intents(config_intents)
|
||||
|
||||
await async_setup_default_agent(hass, entity_component)
|
||||
|
||||
async def handle_process(service: ServiceCall) -> ServiceResponse:
|
||||
"""Parse text into commands."""
|
||||
@@ -294,9 +300,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def handle_reload(service: ServiceCall) -> None:
|
||||
"""Reload intents."""
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
language = service.data.get(ATTR_LANGUAGE)
|
||||
if language is None:
|
||||
conf = await async_integration_yaml_config(hass, DOMAIN)
|
||||
if conf is not None:
|
||||
config_intents = _get_config_intents(conf, hass_config_path)
|
||||
manager.update_config_intents(config_intents)
|
||||
|
||||
agent = manager.default_agent
|
||||
if agent is not None:
|
||||
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
|
||||
await agent.async_reload(language=language)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
@@ -313,6 +326,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _get_config_intents(config: ConfigType, hass_config_path: str) -> dict[str, Any]:
|
||||
"""Return config intents."""
|
||||
intents = config.get(DOMAIN, {}).get("intents", {})
|
||||
return {
|
||||
"intents": {
|
||||
intent_name: {
|
||||
"data": [
|
||||
{
|
||||
"sentences": sentences,
|
||||
"metadata": {
|
||||
METADATA_CUSTOM_SENTENCE: True,
|
||||
METADATA_CUSTOM_FILE: hass_config_path,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
for intent_name, sentences in intents.items()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||
|
||||
@@ -147,6 +147,7 @@ class AgentManager:
|
||||
self.hass = hass
|
||||
self._agents: dict[str, AbstractConversationAgent] = {}
|
||||
self.default_agent: DefaultAgent | None = None
|
||||
self.config_intents: dict[str, Any] = {}
|
||||
self.triggers_details: list[TriggerDetails] = []
|
||||
|
||||
@callback
|
||||
@@ -199,9 +200,16 @@ class AgentManager:
|
||||
|
||||
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
|
||||
"""Set up the default agent."""
|
||||
agent.update_config_intents(self.config_intents)
|
||||
agent.update_triggers(self.triggers_details)
|
||||
self.default_agent = agent
|
||||
|
||||
def update_config_intents(self, intents: dict[str, Any]) -> None:
|
||||
"""Update config intents."""
|
||||
self.config_intents = intents
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_config_intents(intents)
|
||||
|
||||
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
|
||||
"""Register a trigger."""
|
||||
self.triggers_details.append(trigger_details)
|
||||
|
||||
@@ -30,3 +30,7 @@ class ConversationEntityFeature(IntFlag):
|
||||
"""Supported features of the conversation entity."""
|
||||
|
||||
CONTROL = 1
|
||||
|
||||
|
||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
|
||||
@@ -77,7 +77,12 @@ from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .agent_manager import get_agent_manager
|
||||
from .chat_log import AssistantContent, ChatLog
|
||||
from .const import DOMAIN, ConversationEntityFeature
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
ConversationEntityFeature,
|
||||
)
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput, ConversationResult
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
@@ -91,8 +96,6 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
|
||||
|
||||
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
|
||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
|
||||
|
||||
ERROR_SENTINEL = object()
|
||||
@@ -202,10 +205,9 @@ class IntentCache:
|
||||
async def async_setup_default_agent(
|
||||
hass: HomeAssistant,
|
||||
entity_component: EntityComponent[ConversationEntity],
|
||||
config_intents: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set up entity registry listener for the default agent."""
|
||||
agent = DefaultAgent(hass, config_intents)
|
||||
agent = DefaultAgent(hass)
|
||||
await entity_component.async_add_entities([agent])
|
||||
await get_agent_manager(hass).async_setup_default_agent(agent)
|
||||
|
||||
@@ -230,14 +232,14 @@ class DefaultAgent(ConversationEntity):
|
||||
_attr_name = "Home Assistant"
|
||||
_attr_supported_features = ConversationEntityFeature.CONTROL
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the default agent."""
|
||||
self.hass = hass
|
||||
self._lang_intents: dict[str, LanguageIntents | object] = {}
|
||||
self._load_intents_lock = asyncio.Lock()
|
||||
|
||||
# intent -> [sentences]
|
||||
self._config_intents: dict[str, Any] = config_intents
|
||||
# Intents from common conversation config
|
||||
self._config_intents: dict[str, Any] = {}
|
||||
|
||||
# Sentences that will trigger a callback (skipping intent recognition)
|
||||
self._triggers_details: list[TriggerDetails] = []
|
||||
@@ -1035,6 +1037,14 @@ class DefaultAgent(ConversationEntity):
|
||||
# Intents have changed, so we must clear the cache
|
||||
self._intent_cache.clear()
|
||||
|
||||
@callback
|
||||
def update_config_intents(self, intents: dict[str, Any]) -> None:
|
||||
"""Update config intents."""
|
||||
self._config_intents = intents
|
||||
|
||||
# Intents have changed, so we must clear the cache
|
||||
self._intent_cache.clear()
|
||||
|
||||
async def async_prepare(self, language: str | None = None) -> None:
|
||||
"""Load intents for a language."""
|
||||
if language is None:
|
||||
@@ -1159,33 +1169,10 @@ class DefaultAgent(ConversationEntity):
|
||||
custom_sentences_path,
|
||||
)
|
||||
|
||||
# Load sentences from HA config for default language only
|
||||
if self._config_intents and (
|
||||
self.hass.config.language in (language, language_variant)
|
||||
):
|
||||
hass_config_path = self.hass.config.path()
|
||||
merge_dict(
|
||||
intents_dict,
|
||||
{
|
||||
"intents": {
|
||||
intent_name: {
|
||||
"data": [
|
||||
{
|
||||
"sentences": sentences,
|
||||
"metadata": {
|
||||
METADATA_CUSTOM_SENTENCE: True,
|
||||
METADATA_CUSTOM_FILE: hass_config_path,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
for intent_name, sentences in self._config_intents.items()
|
||||
}
|
||||
},
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Loaded intents from configuration.yaml",
|
||||
)
|
||||
merge_dict(
|
||||
intents_dict,
|
||||
self._config_intents,
|
||||
)
|
||||
|
||||
if not intents_dict:
|
||||
return None
|
||||
|
||||
1
homeassistant/components/cosori/__init__.py
Normal file
1
homeassistant/components/cosori/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Cosori."""
|
||||
6
homeassistant/components/cosori/manifest.json
Normal file
6
homeassistant/components/cosori/manifest.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "cosori",
|
||||
"name": "Cosori",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "vesync"
|
||||
}
|
||||
@@ -9,6 +9,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import (
|
||||
CONF_AUTHORIZE_STRING,
|
||||
@@ -31,9 +32,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool
|
||||
expires_at=entry.data[CONF_EXPIRES_AT],
|
||||
)
|
||||
cync_auth = Auth(async_get_clientsession(hass), user=user_info)
|
||||
ssl_context = get_default_context()
|
||||
|
||||
try:
|
||||
cync = await Cync.create(cync_auth)
|
||||
cync = await Cync.create(
|
||||
auth=cync_auth,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
except AuthFailedError as ex:
|
||||
raise ConfigEntryAuthFailed("User token invalid") from ex
|
||||
except CyncError as ex:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client"],
|
||||
"requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"],
|
||||
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["async-upnp-client==0.45.0"],
|
||||
"requirements": ["async-upnp-client==0.46.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyecobee"],
|
||||
"requirements": ["python-ecobee-api==0.2.20"],
|
||||
"requirements": ["python-ecobee-api==0.3.2"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": [
|
||||
{
|
||||
|
||||
84
homeassistant/components/emoncms/quality_scale.yaml
Normal file
84
homeassistant/components/emoncms/quality_scale.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
rules:
|
||||
# todo : add get_feed_list to the library
|
||||
# todo : see if we can drop some extra attributes
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
test_reconfigure_api_error should use a mock config entry fixture
|
||||
test_user_flow_failure should use a mock config entry fixture
|
||||
move test_user_flow_* to the top of the file
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No events are explicitly registered by the integration.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
test the entry state in test_failure
|
||||
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide any automation
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: change device_class=SensorDeviceClass.SIGNAL_STRENGTH to SOUND_PRESSURE
|
||||
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
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections import Counter
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Literal, TypedDict
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -29,7 +29,7 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
|
||||
class FlowFromGridSourceType(TypedDict):
|
||||
"""Dictionary describing the 'from' stat for the grid source."""
|
||||
|
||||
# statistic_id of a an energy meter (kWh)
|
||||
# statistic_id of an energy meter (kWh)
|
||||
stat_energy_from: str
|
||||
|
||||
# statistic_id of costs ($) incurred from the energy meter
|
||||
@@ -58,6 +58,14 @@ class FlowToGridSourceType(TypedDict):
|
||||
number_energy_price: float | None # Price for energy ($/kWh)
|
||||
|
||||
|
||||
class GridPowerSourceType(TypedDict):
|
||||
"""Dictionary holding the source of grid power consumption."""
|
||||
|
||||
# statistic_id of a power meter (kW)
|
||||
# negative values indicate grid return
|
||||
stat_rate: str
|
||||
|
||||
|
||||
class GridSourceType(TypedDict):
|
||||
"""Dictionary holding the source of grid energy consumption."""
|
||||
|
||||
@@ -65,6 +73,7 @@ class GridSourceType(TypedDict):
|
||||
|
||||
flow_from: list[FlowFromGridSourceType]
|
||||
flow_to: list[FlowToGridSourceType]
|
||||
power: NotRequired[list[GridPowerSourceType]]
|
||||
|
||||
cost_adjustment_day: float
|
||||
|
||||
@@ -75,6 +84,7 @@ class SolarSourceType(TypedDict):
|
||||
type: Literal["solar"]
|
||||
|
||||
stat_energy_from: str
|
||||
stat_rate: NotRequired[str]
|
||||
config_entry_solar_forecast: list[str] | None
|
||||
|
||||
|
||||
@@ -85,6 +95,8 @@ class BatterySourceType(TypedDict):
|
||||
|
||||
stat_energy_from: str
|
||||
stat_energy_to: str
|
||||
# positive when discharging, negative when charging
|
||||
stat_rate: NotRequired[str]
|
||||
|
||||
|
||||
class GasSourceType(TypedDict):
|
||||
@@ -136,12 +148,15 @@ class DeviceConsumption(TypedDict):
|
||||
# This is an ever increasing value
|
||||
stat_consumption: str
|
||||
|
||||
# Instantaneous rate of flow: W, L/min or m³/h
|
||||
stat_rate: NotRequired[str]
|
||||
|
||||
# An optional custom name for display in energy graphs
|
||||
name: str | None
|
||||
|
||||
# An optional statistic_id identifying a device
|
||||
# that includes this device's consumption in its total
|
||||
included_in_stat: str | None
|
||||
included_in_stat: NotRequired[str]
|
||||
|
||||
|
||||
class EnergyPreferences(TypedDict):
|
||||
@@ -194,6 +209,12 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("stat_rate"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
|
||||
"""Generate a validator that ensures a value is only used once."""
|
||||
@@ -224,6 +245,10 @@ GRID_SOURCE_SCHEMA = vol.Schema(
|
||||
[FLOW_TO_GRID_SOURCE_SCHEMA],
|
||||
_generate_unique_value_validator("stat_energy_to"),
|
||||
),
|
||||
vol.Optional("power"): vol.All(
|
||||
[GRID_POWER_SOURCE_SCHEMA],
|
||||
_generate_unique_value_validator("stat_rate"),
|
||||
),
|
||||
vol.Required("cost_adjustment_day"): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
@@ -231,6 +256,7 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "solar",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
|
||||
}
|
||||
)
|
||||
@@ -239,6 +265,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
||||
vol.Required("type"): "battery",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Required("stat_energy_to"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
}
|
||||
)
|
||||
GAS_SOURCE_SCHEMA = vol.Schema(
|
||||
@@ -294,6 +321,7 @@ ENERGY_SOURCE_SCHEMA = vol.All(
|
||||
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("stat_consumption"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("name"): str,
|
||||
vol.Optional("included_in_stat"): str,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.const import (
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
||||
@@ -23,12 +24,17 @@ ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
|
||||
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
|
||||
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
|
||||
}
|
||||
POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
|
||||
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
|
||||
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
|
||||
}
|
||||
|
||||
ENERGY_PRICE_UNITS = tuple(
|
||||
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
|
||||
)
|
||||
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
|
||||
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
|
||||
POWER_UNIT_ERROR = "entity_unexpected_unit_power"
|
||||
GAS_USAGE_DEVICE_CLASSES = (
|
||||
sensor.SensorDeviceClass.ENERGY,
|
||||
sensor.SensorDeviceClass.GAS,
|
||||
@@ -82,6 +88,10 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
|
||||
f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
|
||||
),
|
||||
}
|
||||
if issue_type == POWER_UNIT_ERROR:
|
||||
return {
|
||||
"power_units": ", ".join(POWER_USAGE_UNITS[sensor.SensorDeviceClass.POWER]),
|
||||
}
|
||||
if issue_type == GAS_UNIT_ERROR:
|
||||
return {
|
||||
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
|
||||
@@ -159,7 +169,7 @@ class EnergyPreferencesValidation:
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_usage_stat(
|
||||
def _async_validate_stat_common(
|
||||
hass: HomeAssistant,
|
||||
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
stat_id: str,
|
||||
@@ -167,37 +177,41 @@ def _async_validate_usage_stat(
|
||||
allowed_units: Mapping[str, Sequence[str]],
|
||||
unit_error: str,
|
||||
issues: ValidationIssues,
|
||||
) -> None:
|
||||
"""Validate a statistic."""
|
||||
check_negative: bool = False,
|
||||
) -> str | None:
|
||||
"""Validate common aspects of a statistic.
|
||||
|
||||
Returns the entity_id if validation succeeds, None otherwise.
|
||||
"""
|
||||
if stat_id not in metadata:
|
||||
issues.add_issue(hass, "statistics_not_defined", stat_id)
|
||||
|
||||
has_entity_source = valid_entity_id(stat_id)
|
||||
|
||||
if not has_entity_source:
|
||||
return
|
||||
return None
|
||||
|
||||
entity_id = stat_id
|
||||
|
||||
if not recorder.is_entity_recorded(hass, entity_id):
|
||||
issues.add_issue(hass, "recorder_untracked", entity_id)
|
||||
return
|
||||
return None
|
||||
|
||||
if (state := hass.states.get(entity_id)) is None:
|
||||
issues.add_issue(hass, "entity_not_defined", entity_id)
|
||||
return
|
||||
return None
|
||||
|
||||
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
|
||||
return
|
||||
return None
|
||||
|
||||
try:
|
||||
current_value: float | None = float(state.state)
|
||||
except ValueError:
|
||||
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
|
||||
return
|
||||
return None
|
||||
|
||||
if current_value is not None and current_value < 0:
|
||||
if check_negative and current_value is not None and current_value < 0:
|
||||
issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
|
||||
|
||||
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
@@ -211,6 +225,36 @@ def _async_validate_usage_stat(
|
||||
if device_class and unit not in allowed_units.get(device_class, []):
|
||||
issues.add_issue(hass, unit_error, entity_id, unit)
|
||||
|
||||
return entity_id
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_usage_stat(
|
||||
hass: HomeAssistant,
|
||||
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
stat_id: str,
|
||||
allowed_device_classes: Sequence[str],
|
||||
allowed_units: Mapping[str, Sequence[str]],
|
||||
unit_error: str,
|
||||
issues: ValidationIssues,
|
||||
) -> None:
|
||||
"""Validate a statistic."""
|
||||
entity_id = _async_validate_stat_common(
|
||||
hass,
|
||||
metadata,
|
||||
stat_id,
|
||||
allowed_device_classes,
|
||||
allowed_units,
|
||||
unit_error,
|
||||
issues,
|
||||
check_negative=True,
|
||||
)
|
||||
|
||||
if entity_id is None:
|
||||
return
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
|
||||
|
||||
allowed_state_classes = [
|
||||
@@ -255,6 +299,39 @@ def _async_validate_price_entity(
|
||||
issues.add_issue(hass, unit_error, entity_id, unit)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_power_stat(
|
||||
hass: HomeAssistant,
|
||||
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
stat_id: str,
|
||||
allowed_device_classes: Sequence[str],
|
||||
allowed_units: Mapping[str, Sequence[str]],
|
||||
unit_error: str,
|
||||
issues: ValidationIssues,
|
||||
) -> None:
|
||||
"""Validate a power statistic."""
|
||||
entity_id = _async_validate_stat_common(
|
||||
hass,
|
||||
metadata,
|
||||
stat_id,
|
||||
allowed_device_classes,
|
||||
allowed_units,
|
||||
unit_error,
|
||||
issues,
|
||||
check_negative=False,
|
||||
)
|
||||
|
||||
if entity_id is None:
|
||||
return
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
|
||||
|
||||
if state_class != sensor.SensorStateClass.MEASUREMENT:
|
||||
issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_cost_stat(
|
||||
hass: HomeAssistant,
|
||||
@@ -309,11 +386,260 @@ def _async_validate_auto_generated_cost_entity(
|
||||
issues.add_issue(hass, "recorder_untracked", cost_entity_id)
|
||||
|
||||
|
||||
def _validate_grid_source(
|
||||
hass: HomeAssistant,
|
||||
source: data.GridSourceType,
|
||||
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
wanted_statistics_metadata: set[str],
|
||||
source_result: ValidationIssues,
|
||||
validate_calls: list[functools.partial[None]],
|
||||
) -> None:
|
||||
"""Validate grid energy source."""
|
||||
flow_from: data.FlowFromGridSourceType
|
||||
for flow_from in source["flow_from"]:
|
||||
wanted_statistics_metadata.add(flow_from["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
flow_from["stat_energy_from"],
|
||||
ENERGY_USAGE_DEVICE_CLASSES,
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
if (stat_cost := flow_from.get("stat_cost")) is not None:
|
||||
wanted_statistics_metadata.add(stat_cost)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_cost,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (entity_energy_price := flow_from.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
ENERGY_PRICE_UNITS,
|
||||
ENERGY_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
flow_from.get("entity_energy_price") is not None
|
||||
or flow_from.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
flow_from["stat_energy_from"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
flow_to: data.FlowToGridSourceType
|
||||
for flow_to in source["flow_to"]:
|
||||
wanted_statistics_metadata.add(flow_to["stat_energy_to"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
flow_to["stat_energy_to"],
|
||||
ENERGY_USAGE_DEVICE_CLASSES,
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
if (stat_compensation := flow_to.get("stat_compensation")) is not None:
|
||||
wanted_statistics_metadata.add(stat_compensation)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_compensation,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (entity_energy_price := flow_to.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
ENERGY_PRICE_UNITS,
|
||||
ENERGY_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
flow_to.get("entity_energy_price") is not None
|
||||
or flow_to.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
flow_to["stat_energy_to"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
for power_stat in source.get("power", []):
|
||||
wanted_statistics_metadata.add(power_stat["stat_rate"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_power_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
power_stat["stat_rate"],
|
||||
POWER_USAGE_DEVICE_CLASSES,
|
||||
POWER_USAGE_UNITS,
|
||||
POWER_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _validate_gas_source(
|
||||
hass: HomeAssistant,
|
||||
source: data.GasSourceType,
|
||||
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
wanted_statistics_metadata: set[str],
|
||||
source_result: ValidationIssues,
|
||||
validate_calls: list[functools.partial[None]],
|
||||
) -> None:
|
||||
"""Validate gas energy source."""
|
||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
source["stat_energy_from"],
|
||||
GAS_USAGE_DEVICE_CLASSES,
|
||||
GAS_USAGE_UNITS,
|
||||
GAS_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
if (stat_cost := source.get("stat_cost")) is not None:
|
||||
wanted_statistics_metadata.add(stat_cost)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_cost,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
GAS_PRICE_UNITS,
|
||||
GAS_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
source.get("entity_energy_price") is not None
|
||||
or source.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _validate_water_source(
|
||||
hass: HomeAssistant,
|
||||
source: data.WaterSourceType,
|
||||
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
wanted_statistics_metadata: set[str],
|
||||
source_result: ValidationIssues,
|
||||
validate_calls: list[functools.partial[None]],
|
||||
) -> None:
|
||||
"""Validate water energy source."""
|
||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
source["stat_energy_from"],
|
||||
WATER_USAGE_DEVICE_CLASSES,
|
||||
WATER_USAGE_UNITS,
|
||||
WATER_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
if (stat_cost := source.get("stat_cost")) is not None:
|
||||
wanted_statistics_metadata.add(stat_cost)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_cost,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
WATER_PRICE_UNITS,
|
||||
WATER_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
source.get("entity_energy_price") is not None
|
||||
or source.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
"""Validate the energy configuration."""
|
||||
manager: data.EnergyManager = await data.async_get_manager(hass)
|
||||
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]] = {}
|
||||
validate_calls = []
|
||||
validate_calls: list[functools.partial[None]] = []
|
||||
wanted_statistics_metadata: set[str] = set()
|
||||
|
||||
result = EnergyPreferencesValidation()
|
||||
@@ -327,215 +653,35 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
result.energy_sources.append(source_result)
|
||||
|
||||
if source["type"] == "grid":
|
||||
flow: data.FlowFromGridSourceType | data.FlowToGridSourceType
|
||||
for flow in source["flow_from"]:
|
||||
wanted_statistics_metadata.add(flow["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
flow["stat_energy_from"],
|
||||
ENERGY_USAGE_DEVICE_CLASSES,
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
if (stat_cost := flow.get("stat_cost")) is not None:
|
||||
wanted_statistics_metadata.add(stat_cost)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_cost,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (
|
||||
entity_energy_price := flow.get("entity_energy_price")
|
||||
) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
ENERGY_PRICE_UNITS,
|
||||
ENERGY_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
flow.get("entity_energy_price") is not None
|
||||
or flow.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
flow["stat_energy_from"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
for flow in source["flow_to"]:
|
||||
wanted_statistics_metadata.add(flow["stat_energy_to"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
flow["stat_energy_to"],
|
||||
ENERGY_USAGE_DEVICE_CLASSES,
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
if (stat_compensation := flow.get("stat_compensation")) is not None:
|
||||
wanted_statistics_metadata.add(stat_compensation)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_compensation,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (
|
||||
entity_energy_price := flow.get("entity_energy_price")
|
||||
) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
ENERGY_PRICE_UNITS,
|
||||
ENERGY_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
flow.get("entity_energy_price") is not None
|
||||
or flow.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
flow["stat_energy_to"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
_validate_grid_source(
|
||||
hass,
|
||||
source,
|
||||
statistics_metadata,
|
||||
wanted_statistics_metadata,
|
||||
source_result,
|
||||
validate_calls,
|
||||
)
|
||||
|
||||
elif source["type"] == "gas":
|
||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
source["stat_energy_from"],
|
||||
GAS_USAGE_DEVICE_CLASSES,
|
||||
GAS_USAGE_UNITS,
|
||||
GAS_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
_validate_gas_source(
|
||||
hass,
|
||||
source,
|
||||
statistics_metadata,
|
||||
wanted_statistics_metadata,
|
||||
source_result,
|
||||
validate_calls,
|
||||
)
|
||||
|
||||
if (stat_cost := source.get("stat_cost")) is not None:
|
||||
wanted_statistics_metadata.add(stat_cost)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_cost,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
GAS_PRICE_UNITS,
|
||||
GAS_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
source.get("entity_energy_price") is not None
|
||||
or source.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
elif source["type"] == "water":
|
||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
source["stat_energy_from"],
|
||||
WATER_USAGE_DEVICE_CLASSES,
|
||||
WATER_USAGE_UNITS,
|
||||
WATER_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
_validate_water_source(
|
||||
hass,
|
||||
source,
|
||||
statistics_metadata,
|
||||
wanted_statistics_metadata,
|
||||
source_result,
|
||||
validate_calls,
|
||||
)
|
||||
|
||||
if (stat_cost := source.get("stat_cost")) is not None:
|
||||
wanted_statistics_metadata.add(stat_cost)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_cost,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
WATER_PRICE_UNITS,
|
||||
WATER_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
source.get("entity_energy_price") is not None
|
||||
or source.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
elif source["type"] == "solar":
|
||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
|
||||
@@ -147,6 +147,8 @@ async def async_get_config_entry_diagnostics(
|
||||
"ctmeter_production_phases": envoy_data.ctmeter_production_phases,
|
||||
"ctmeter_consumption_phases": envoy_data.ctmeter_consumption_phases,
|
||||
"ctmeter_storage_phases": envoy_data.ctmeter_storage_phases,
|
||||
"ctmeters": envoy_data.ctmeters,
|
||||
"ctmeters_phases": envoy_data.ctmeters_phases,
|
||||
"dry_contact_status": envoy_data.dry_contact_status,
|
||||
"dry_contact_settings": envoy_data.dry_contact_settings,
|
||||
"inverters": envoy_data.inverters,
|
||||
@@ -179,6 +181,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"ct_consumption_meter": envoy.consumption_meter_type,
|
||||
"ct_production_meter": envoy.production_meter_type,
|
||||
"ct_storage_meter": envoy.storage_meter_type,
|
||||
"ct_meters": list(envoy_data.ctmeters.keys()),
|
||||
}
|
||||
|
||||
fixture_data: dict[str, Any] = {}
|
||||
|
||||
@@ -399,330 +399,189 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
|
||||
cttype: str | None = None
|
||||
|
||||
|
||||
CT_NET_CONSUMPTION_SENSORS = (
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="lifetime_net_consumption",
|
||||
translation_key="lifetime_net_consumption",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_delivered"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="lifetime_net_production",
|
||||
translation_key="lifetime_net_production",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_received"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_consumption",
|
||||
translation_key="net_consumption",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("active_power"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="frequency",
|
||||
translation_key="net_ct_frequency",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="voltage",
|
||||
translation_key="net_ct_voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_ct_current",
|
||||
translation_key="net_ct_current",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
suggested_display_precision=3,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_ct_powerfactor",
|
||||
translation_key="net_ct_powerfactor",
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_consumption_ct_metering_status",
|
||||
translation_key="net_ct_metering_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
options=list(CtMeterStatus),
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_consumption_ct_status_flags",
|
||||
translation_key="net_ct_status_flags",
|
||||
state_class=None,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
CT_NET_CONSUMPTION_PHASE_SENSORS = {
|
||||
(on_phase := PHASENAMES[phase]): [
|
||||
replace(
|
||||
sensor,
|
||||
key=f"{sensor.key}_l{phase + 1}",
|
||||
translation_key=f"{sensor.translation_key}_phase",
|
||||
entity_registry_enabled_default=False,
|
||||
on_phase=on_phase,
|
||||
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
||||
# All ct types unified in common setup
|
||||
CT_SENSORS = (
|
||||
[
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=key,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_delivered"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "lifetime_net_consumption"),
|
||||
# Production CT energy_delivered is not used
|
||||
(CtType.STORAGE, "lifetime_battery_discharged"),
|
||||
)
|
||||
for sensor in list(CT_NET_CONSUMPTION_SENSORS)
|
||||
]
|
||||
for phase in range(3)
|
||||
}
|
||||
|
||||
CT_PRODUCTION_SENSORS = (
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_frequency",
|
||||
translation_key="production_ct_frequency",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_voltage",
|
||||
translation_key="production_ct_voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_current",
|
||||
translation_key="production_ct_current",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
suggested_display_precision=3,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_powerfactor",
|
||||
translation_key="production_ct_powerfactor",
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_metering_status",
|
||||
translation_key="production_ct_metering_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(CtMeterStatus),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_status_flags",
|
||||
translation_key="production_ct_status_flags",
|
||||
state_class=None,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
)
|
||||
|
||||
CT_PRODUCTION_PHASE_SENSORS = {
|
||||
(on_phase := PHASENAMES[phase]): [
|
||||
replace(
|
||||
sensor,
|
||||
key=f"{sensor.key}_l{phase + 1}",
|
||||
translation_key=f"{sensor.translation_key}_phase",
|
||||
entity_registry_enabled_default=False,
|
||||
on_phase=on_phase,
|
||||
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=key,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_received"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "lifetime_net_production"),
|
||||
# Production CT energy_received is not used
|
||||
(CtType.STORAGE, "lifetime_battery_charged"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=key,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("active_power"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "net_consumption"),
|
||||
# Production CT active_power is not used
|
||||
(CtType.STORAGE, "battery_discharge"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=(translation_key if translation_key != "" else key),
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
(CtType.NET_CONSUMPTION, "frequency", "net_ct_frequency"),
|
||||
(CtType.PRODUCTION, "production_ct_frequency", ""),
|
||||
(CtType.STORAGE, "storage_ct_frequency", ""),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=(translation_key if translation_key != "" else key),
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
(CtType.NET_CONSUMPTION, "voltage", "net_ct_voltage"),
|
||||
(CtType.PRODUCTION, "production_ct_voltage", ""),
|
||||
(CtType.STORAGE, "storage_voltage", "storage_ct_voltage"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=key,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
suggested_display_precision=3,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "net_ct_current"),
|
||||
(CtType.PRODUCTION, "production_ct_current"),
|
||||
(CtType.STORAGE, "storage_ct_current"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=key,
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "net_ct_powerfactor"),
|
||||
(CtType.PRODUCTION, "production_ct_powerfactor"),
|
||||
(CtType.STORAGE, "storage_ct_powerfactor"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=(translation_key if translation_key != "" else key),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
options=list(CtMeterStatus),
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
(
|
||||
CtType.NET_CONSUMPTION,
|
||||
"net_consumption_ct_metering_status",
|
||||
"net_ct_metering_status",
|
||||
),
|
||||
(CtType.PRODUCTION, "production_ct_metering_status", ""),
|
||||
(CtType.STORAGE, "storage_ct_metering_status", ""),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=(translation_key if translation_key != "" else key),
|
||||
state_class=None,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
(
|
||||
CtType.NET_CONSUMPTION,
|
||||
"net_consumption_ct_status_flags",
|
||||
"net_ct_status_flags",
|
||||
),
|
||||
(CtType.PRODUCTION, "production_ct_status_flags", ""),
|
||||
(CtType.STORAGE, "storage_ct_status_flags", ""),
|
||||
)
|
||||
for sensor in list(CT_PRODUCTION_SENSORS)
|
||||
]
|
||||
for phase in range(3)
|
||||
}
|
||||
|
||||
CT_STORAGE_SENSORS = (
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="lifetime_battery_discharged",
|
||||
translation_key="lifetime_battery_discharged",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_delivered"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="lifetime_battery_charged",
|
||||
translation_key="lifetime_battery_charged",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_received"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="battery_discharge",
|
||||
translation_key="battery_discharge",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("active_power"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_frequency",
|
||||
translation_key="storage_ct_frequency",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_voltage",
|
||||
translation_key="storage_ct_voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_current",
|
||||
translation_key="storage_ct_current",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
suggested_display_precision=3,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_powerfactor",
|
||||
translation_key="storage_ct_powerfactor",
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_metering_status",
|
||||
translation_key="storage_ct_metering_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(CtMeterStatus),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_status_flags",
|
||||
translation_key="storage_ct_status_flags",
|
||||
state_class=None,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
CT_STORAGE_PHASE_SENSORS = {
|
||||
CT_PHASE_SENSORS = {
|
||||
(on_phase := PHASENAMES[phase]): [
|
||||
replace(
|
||||
sensor,
|
||||
@@ -732,7 +591,7 @@ CT_STORAGE_PHASE_SENSORS = {
|
||||
on_phase=on_phase,
|
||||
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
||||
)
|
||||
for sensor in list(CT_STORAGE_SENSORS)
|
||||
for sensor in list(CT_SENSORS)
|
||||
]
|
||||
for phase in range(3)
|
||||
}
|
||||
@@ -1060,24 +919,14 @@ async def async_setup_entry(
|
||||
if envoy_data.ctmeters:
|
||||
entities.extend(
|
||||
EnvoyCTEntity(coordinator, description)
|
||||
for sensors in (
|
||||
CT_NET_CONSUMPTION_SENSORS,
|
||||
CT_PRODUCTION_SENSORS,
|
||||
CT_STORAGE_SENSORS,
|
||||
)
|
||||
for description in sensors
|
||||
for description in CT_SENSORS
|
||||
if description.cttype in envoy_data.ctmeters
|
||||
)
|
||||
# Add Current Transformer phase entities
|
||||
if ctmeters_phases := envoy_data.ctmeters_phases:
|
||||
entities.extend(
|
||||
EnvoyCTPhaseEntity(coordinator, description)
|
||||
for sensors in (
|
||||
CT_NET_CONSUMPTION_PHASE_SENSORS,
|
||||
CT_PRODUCTION_PHASE_SENSORS,
|
||||
CT_STORAGE_PHASE_SENSORS,
|
||||
)
|
||||
for phase, descriptions in sensors.items()
|
||||
for phase, descriptions in CT_PHASE_SENSORS.items()
|
||||
for description in descriptions
|
||||
if (cttype := description.cttype) in ctmeters_phases
|
||||
and phase in ctmeters_phases[cttype]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from urllib.parse import quote
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -152,7 +153,9 @@ class HassFoscamCamera(FoscamEntity, Camera):
|
||||
async def stream_source(self) -> str | None:
|
||||
"""Return the stream source."""
|
||||
if self._rtsp_port:
|
||||
return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
|
||||
_username = quote(self._username)
|
||||
_password = quote(self._password)
|
||||
return f"rtsp://{_username}:{_password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -116,20 +116,28 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
||||
is_open_wdr = None
|
||||
is_open_hdr = None
|
||||
reserve3 = product_info.get("reserve4")
|
||||
reserve3_int = int(reserve3) if reserve3 is not None else 0
|
||||
supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
|
||||
supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
|
||||
if supports_wdr_adjustment_val:
|
||||
ret_wdr, is_open_wdr_data = self.session.getWdrMode()
|
||||
mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
|
||||
is_open_wdr = bool(int(mode))
|
||||
elif supports_hdr_adjustment_val:
|
||||
ret_hdr, is_open_hdr_data = self.session.getHdrMode()
|
||||
mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
|
||||
is_open_hdr = bool(int(mode))
|
||||
|
||||
model = product_info.get("model")
|
||||
model_int = int(model) if model is not None else 7002
|
||||
if model_int > 7001:
|
||||
reserve3_int = int(reserve3) if reserve3 is not None else 0
|
||||
supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
|
||||
supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
|
||||
if supports_wdr_adjustment_val:
|
||||
ret_wdr, is_open_wdr_data = self.session.getWdrMode()
|
||||
mode = (
|
||||
is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
|
||||
)
|
||||
is_open_wdr = bool(int(mode))
|
||||
elif supports_hdr_adjustment_val:
|
||||
ret_hdr, is_open_hdr_data = self.session.getHdrMode()
|
||||
mode = (
|
||||
is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
|
||||
)
|
||||
is_open_hdr = bool(int(mode))
|
||||
else:
|
||||
supports_wdr_adjustment_val = False
|
||||
supports_hdr_adjustment_val = False
|
||||
ret_sw, software_capabilities = self.session.getSWCapabilities()
|
||||
|
||||
supports_speak_volume_adjustment_val = (
|
||||
bool(int(software_capabilities.get("swCapabilities1")) & 32)
|
||||
if ret_sw == 0
|
||||
|
||||
@@ -481,6 +481,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
sidebar_title="climate",
|
||||
sidebar_default_visible=False,
|
||||
)
|
||||
async_register_built_in_panel(
|
||||
hass,
|
||||
"home",
|
||||
sidebar_icon="mdi:home",
|
||||
sidebar_title="home",
|
||||
sidebar_default_visible=False,
|
||||
)
|
||||
|
||||
async_register_built_in_panel(hass, "profile")
|
||||
|
||||
@@ -770,7 +777,9 @@ class ManifestJSONView(HomeAssistantView):
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "frontend/get_icons",
|
||||
vol.Required("category"): vol.In({"entity", "entity_component", "services"}),
|
||||
vol.Required("category"): vol.In(
|
||||
{"entity", "entity_component", "services", "triggers", "conditions"}
|
||||
),
|
||||
vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""The Goodwe inverter component."""
|
||||
|
||||
from goodwe import InverterError, connect
|
||||
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
|
||||
from goodwe import Inverter, InverterError, connect
|
||||
from goodwe.const import GOODWE_UDP_PORT
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
|
||||
from .config_flow import GoodweFlowHandler
|
||||
from .const import CONF_MODEL_FAMILY, DOMAIN, PLATFORMS
|
||||
from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoordinator
|
||||
|
||||
@@ -15,28 +16,22 @@ from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoord
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bool:
|
||||
"""Set up the Goodwe components from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
port = entry.data.get(CONF_PORT, GOODWE_UDP_PORT)
|
||||
model_family = entry.data[CONF_MODEL_FAMILY]
|
||||
|
||||
# Connect to Goodwe inverter
|
||||
try:
|
||||
inverter = await connect(
|
||||
host=host,
|
||||
port=GOODWE_UDP_PORT,
|
||||
port=port,
|
||||
family=model_family,
|
||||
retries=10,
|
||||
)
|
||||
except InverterError as err_udp:
|
||||
# First try with UDP failed, trying with the TCP port
|
||||
except InverterError as err:
|
||||
try:
|
||||
inverter = await connect(
|
||||
host=host,
|
||||
port=GOODWE_TCP_PORT,
|
||||
family=model_family,
|
||||
retries=10,
|
||||
)
|
||||
inverter = await async_check_port(hass, entry, host)
|
||||
except InverterError:
|
||||
# Both ports are unavailable
|
||||
raise ConfigEntryNotReady from err_udp
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
device_info = DeviceInfo(
|
||||
configuration_url="https://www.semsportal.com",
|
||||
@@ -66,6 +61,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo
|
||||
return True
|
||||
|
||||
|
||||
async def async_check_port(
|
||||
hass: HomeAssistant, entry: GoodweConfigEntry, host: str
|
||||
) -> Inverter:
|
||||
"""Check the communication port of the inverter, it may have changed after a firmware update."""
|
||||
inverter, port = await GoodweFlowHandler.async_detect_inverter_port(host=host)
|
||||
family = type(inverter).__name__
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_PORT: port,
|
||||
CONF_MODEL_FAMILY: family,
|
||||
},
|
||||
)
|
||||
return inverter
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: GoodweConfigEntry
|
||||
) -> bool:
|
||||
@@ -76,3 +88,31 @@ async def async_unload_entry(
|
||||
async def update_listener(hass: HomeAssistant, config_entry: GoodweConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, config_entry: GoodweConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old config entries."""
|
||||
|
||||
if config_entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if config_entry.version == 1:
|
||||
# Update from version 1 to version 2 adding the PROTOCOL to the config entry
|
||||
host = config_entry.data[CONF_HOST]
|
||||
try:
|
||||
inverter, port = await GoodweFlowHandler.async_detect_inverter_port(
|
||||
host=host
|
||||
)
|
||||
except InverterError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
new_data = {
|
||||
CONF_HOST: host,
|
||||
CONF_PORT: port,
|
||||
CONF_MODEL_FAMILY: type(inverter).__name__,
|
||||
}
|
||||
hass.config_entries.async_update_entry(config_entry, data=new_data, version=2)
|
||||
|
||||
return True
|
||||
|
||||
@@ -5,12 +5,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from goodwe import InverterError, connect
|
||||
from goodwe import Inverter, InverterError, connect
|
||||
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
|
||||
from .const import CONF_MODEL_FAMILY, DEFAULT_NAME, DOMAIN
|
||||
|
||||
@@ -26,9 +26,15 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Goodwe config flow."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def _handle_successful_connection(self, inverter, host):
|
||||
async def async_handle_successful_connection(
|
||||
self,
|
||||
inverter: Inverter,
|
||||
host: str,
|
||||
port: int,
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a successful connection storing it's values on the entry data."""
|
||||
await self.async_set_unique_id(inverter.serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
@@ -36,6 +42,7 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
title=DEFAULT_NAME,
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_PORT: port,
|
||||
CONF_MODEL_FAMILY: type(inverter).__name__,
|
||||
},
|
||||
)
|
||||
@@ -48,19 +55,26 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
try:
|
||||
inverter = await connect(host=host, port=GOODWE_UDP_PORT, retries=10)
|
||||
inverter, port = await self.async_detect_inverter_port(host=host)
|
||||
except InverterError:
|
||||
try:
|
||||
inverter = await connect(
|
||||
host=host, port=GOODWE_TCP_PORT, retries=10
|
||||
)
|
||||
except InverterError:
|
||||
errors[CONF_HOST] = "connection_error"
|
||||
else:
|
||||
return await self._handle_successful_connection(inverter, host)
|
||||
errors[CONF_HOST] = "connection_error"
|
||||
else:
|
||||
return await self._handle_successful_connection(inverter, host)
|
||||
|
||||
return await self.async_handle_successful_connection(
|
||||
inverter, host, port
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def async_detect_inverter_port(
|
||||
host: str,
|
||||
) -> tuple[Inverter, int]:
|
||||
"""Detects the port of the Inverter."""
|
||||
port = GOODWE_UDP_PORT
|
||||
try:
|
||||
inverter = await connect(host=host, port=port, retries=10)
|
||||
except InverterError:
|
||||
port = GOODWE_TCP_PORT
|
||||
inverter = await connect(host=host, port=port, retries=10)
|
||||
return inverter, port
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["gassist-text==0.0.14"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: No polling.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: No entities to update.
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: This integration acts as a service and does not represent physical devices.
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This is a cloud service integration that cannot be discovered locally.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: No entities to update.
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: No devices.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repairs.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: No devices.
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: The underlying library uses gRPC, not aiohttp/httpx, for communication.
|
||||
strict-typing: done
|
||||
@@ -56,6 +56,9 @@
|
||||
"init": {
|
||||
"data": {
|
||||
"language_code": "Language code"
|
||||
},
|
||||
"data_description": {
|
||||
"language_code": "Language for the Google Assistant SDK requests and responses."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ from .const import DOMAIN
|
||||
if TYPE_CHECKING:
|
||||
from . import GoogleSheetsConfigEntry
|
||||
|
||||
ADD_CREATED_COLUMN = "add_created_column"
|
||||
DATA = "data"
|
||||
DATA_CONFIG_ENTRY = "config_entry"
|
||||
ROWS = "rows"
|
||||
@@ -43,6 +44,7 @@ SHEET_SERVICE_SCHEMA = vol.All(
|
||||
{
|
||||
vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
|
||||
vol.Optional(WORKSHEET): cv.string,
|
||||
vol.Optional(ADD_CREATED_COLUMN, default=True): cv.boolean,
|
||||
vol.Required(DATA): vol.Any(cv.ensure_list, [dict]),
|
||||
},
|
||||
)
|
||||
@@ -69,10 +71,11 @@ def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
|
||||
|
||||
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
|
||||
columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), [])
|
||||
add_created_column = call.data[ADD_CREATED_COLUMN]
|
||||
now = str(datetime.now())
|
||||
rows = []
|
||||
for d in call.data[DATA]:
|
||||
row_data = {"created": now} | d
|
||||
row_data = ({"created": now} | d) if add_created_column else d
|
||||
row = [row_data.get(column, "") for column in columns]
|
||||
for key, value in row_data.items():
|
||||
if key not in columns:
|
||||
|
||||
@@ -9,6 +9,11 @@ append_sheet:
|
||||
example: "Sheet1"
|
||||
selector:
|
||||
text:
|
||||
add_created_column:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
data:
|
||||
required: true
|
||||
example: '{"hello": world, "cool": True, "count": 5}'
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
"append_sheet": {
|
||||
"description": "Appends data to a worksheet in Google Sheets.",
|
||||
"fields": {
|
||||
"add_created_column": {
|
||||
"description": "Add a \"created\" column with the current date-time to the appended data.",
|
||||
"name": "Add created column"
|
||||
},
|
||||
"config_entry": {
|
||||
"description": "The sheet to add data to.",
|
||||
"name": "Sheet"
|
||||
|
||||
@@ -53,6 +53,9 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
|
||||
# Due dates are returned always in UTC so we only need to
|
||||
# parse the date portion which will be interpreted as a a local date.
|
||||
due = datetime.fromisoformat(due_str).date()
|
||||
completed: datetime | None = None
|
||||
if (completed_str := item.get("completed")) is not None:
|
||||
completed = datetime.fromisoformat(completed_str)
|
||||
return TodoItem(
|
||||
summary=item["title"],
|
||||
uid=item["id"],
|
||||
@@ -61,6 +64,7 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
|
||||
TodoItemStatus.NEEDS_ACTION,
|
||||
),
|
||||
due=due,
|
||||
completed=completed,
|
||||
description=item.get("notes"),
|
||||
)
|
||||
|
||||
|
||||
84
homeassistant/components/google_weather/__init__.py
Normal file
84
homeassistant/components/google_weather/__init__.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""The Google Weather integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from google_weather_api import GoogleWeatherApi
|
||||
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_REFERRER
|
||||
from .coordinator import (
|
||||
GoogleWeatherConfigEntry,
|
||||
GoogleWeatherCurrentConditionsCoordinator,
|
||||
GoogleWeatherDailyForecastCoordinator,
|
||||
GoogleWeatherHourlyForecastCoordinator,
|
||||
GoogleWeatherRuntimeData,
|
||||
GoogleWeatherSubEntryRuntimeData,
|
||||
)
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.WEATHER]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Google Weather from a config entry."""
|
||||
|
||||
api = GoogleWeatherApi(
|
||||
session=async_get_clientsession(hass),
|
||||
api_key=entry.data[CONF_API_KEY],
|
||||
referrer=entry.data.get(CONF_REFERRER),
|
||||
language_code=hass.config.language,
|
||||
)
|
||||
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData] = {}
|
||||
for subentry in entry.subentries.values():
|
||||
subentry_runtime_data = GoogleWeatherSubEntryRuntimeData(
|
||||
coordinator_observation=GoogleWeatherCurrentConditionsCoordinator(
|
||||
hass, entry, subentry, api
|
||||
),
|
||||
coordinator_daily_forecast=GoogleWeatherDailyForecastCoordinator(
|
||||
hass, entry, subentry, api
|
||||
),
|
||||
coordinator_hourly_forecast=GoogleWeatherHourlyForecastCoordinator(
|
||||
hass, entry, subentry, api
|
||||
),
|
||||
)
|
||||
subentries_runtime_data[subentry.subentry_id] = subentry_runtime_data
|
||||
tasks = [
|
||||
coro
|
||||
for subentry_runtime_data in subentries_runtime_data.values()
|
||||
for coro in (
|
||||
subentry_runtime_data.coordinator_observation.async_config_entry_first_refresh(),
|
||||
subentry_runtime_data.coordinator_daily_forecast.async_config_entry_first_refresh(),
|
||||
subentry_runtime_data.coordinator_hourly_forecast.async_config_entry_first_refresh(),
|
||||
)
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
entry.runtime_data = GoogleWeatherRuntimeData(
|
||||
api=api,
|
||||
subentries_runtime_data=subentries_runtime_data,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||
|
||||
|
||||
async def async_update_options(
|
||||
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
|
||||
) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
198
homeassistant/components/google_weather/config_flow.py
Normal file
198
homeassistant/components/google_weather/config_flow.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Config flow for the Google Weather integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from google_weather_api import GoogleWeatherApi, GoogleWeatherApiError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LATITUDE,
|
||||
CONF_LOCATION,
|
||||
CONF_LONGITUDE,
|
||||
CONF_NAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig
|
||||
|
||||
from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Optional(SECTION_API_KEY_OPTIONS): section(
|
||||
vol.Schema({vol.Optional(CONF_REFERRER): str}), {"collapsed": True}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _validate_input(
|
||||
user_input: dict[str, Any],
|
||||
api: GoogleWeatherApi,
|
||||
errors: dict[str, str],
|
||||
description_placeholders: dict[str, str],
|
||||
) -> bool:
|
||||
try:
|
||||
await api.async_get_current_conditions(
|
||||
latitude=user_input[CONF_LOCATION][CONF_LATITUDE],
|
||||
longitude=user_input[CONF_LOCATION][CONF_LONGITUDE],
|
||||
)
|
||||
except GoogleWeatherApiError as err:
|
||||
errors["base"] = "cannot_connect"
|
||||
description_placeholders["error_message"] = str(err)
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_location_schema(hass: HomeAssistant) -> vol.Schema:
|
||||
"""Return the schema for a location with default values from the hass config."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME, default=hass.config.location_name): str,
|
||||
vol.Required(
|
||||
CONF_LOCATION,
|
||||
default={
|
||||
CONF_LATITUDE: hass.config.latitude,
|
||||
CONF_LONGITUDE: hass.config.longitude,
|
||||
},
|
||||
): LocationSelector(LocationSelectorConfig(radius=False)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_location_already_configured(
|
||||
hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4
|
||||
) -> bool:
|
||||
"""Check if the location is already configured."""
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
for subentry in entry.subentries.values():
|
||||
# A more accurate way is to use the haversine formula, but for simplicity
|
||||
# we use a simple distance check. The epsilon value is small anyway.
|
||||
# This is mostly to capture cases where the user has slightly moved the location pin.
|
||||
if (
|
||||
abs(subentry.data[CONF_LATITUDE] - new_data[CONF_LATITUDE]) <= epsilon
|
||||
and abs(subentry.data[CONF_LONGITUDE] - new_data[CONF_LONGITUDE])
|
||||
<= epsilon
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Google Weather."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {
|
||||
"api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key",
|
||||
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
|
||||
}
|
||||
if user_input is not None:
|
||||
api_key = user_input[CONF_API_KEY]
|
||||
referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER)
|
||||
self._async_abort_entries_match({CONF_API_KEY: api_key})
|
||||
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
|
||||
return self.async_abort(reason="already_configured")
|
||||
api = GoogleWeatherApi(
|
||||
session=async_get_clientsession(self.hass),
|
||||
api_key=api_key,
|
||||
referrer=referrer,
|
||||
language_code=self.hass.config.language,
|
||||
)
|
||||
if await _validate_input(user_input, api, errors, description_placeholders):
|
||||
return self.async_create_entry(
|
||||
title="Google Weather",
|
||||
data={
|
||||
CONF_API_KEY: api_key,
|
||||
CONF_REFERRER: referrer,
|
||||
},
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": "location",
|
||||
"data": user_input[CONF_LOCATION],
|
||||
"title": user_input[CONF_NAME],
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
else:
|
||||
user_input = {}
|
||||
schema = STEP_USER_DATA_SCHEMA.schema.copy()
|
||||
schema.update(_get_location_schema(self.hass).schema)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(schema), user_input
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {"location": LocationSubentryFlowHandler}
|
||||
|
||||
|
||||
class LocationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle a subentry flow for location."""
|
||||
|
||||
async def async_step_location(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the location step."""
|
||||
if self._get_entry().state != ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
|
||||
return self.async_abort(reason="already_configured")
|
||||
api: GoogleWeatherApi = self._get_entry().runtime_data.api
|
||||
if await _validate_input(user_input, api, errors, description_placeholders):
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME],
|
||||
data=user_input[CONF_LOCATION],
|
||||
)
|
||||
else:
|
||||
user_input = {}
|
||||
return self.async_show_form(
|
||||
step_id="location",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
_get_location_schema(self.hass), user_input
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async_step_user = async_step_location
|
||||
8
homeassistant/components/google_weather/const.py
Normal file
8
homeassistant/components/google_weather/const.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Constants for the Google Weather integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN = "google_weather"
|
||||
|
||||
SECTION_API_KEY_OPTIONS: Final = "api_key_options"
|
||||
CONF_REFERRER: Final = "referrer"
|
||||
169
homeassistant/components/google_weather/coordinator.py
Normal file
169
homeassistant/components/google_weather/coordinator.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""The Google Weather coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TypeVar
|
||||
|
||||
from google_weather_api import (
|
||||
CurrentConditionsResponse,
|
||||
DailyForecastResponse,
|
||||
GoogleWeatherApi,
|
||||
GoogleWeatherApiError,
|
||||
HourlyForecastResponse,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
TimestampDataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar(
|
||||
"T",
|
||||
bound=(
|
||||
CurrentConditionsResponse
|
||||
| DailyForecastResponse
|
||||
| HourlyForecastResponse
|
||||
| None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoogleWeatherSubEntryRuntimeData:
|
||||
"""Runtime data for a Google Weather sub-entry."""
|
||||
|
||||
coordinator_observation: GoogleWeatherCurrentConditionsCoordinator
|
||||
coordinator_daily_forecast: GoogleWeatherDailyForecastCoordinator
|
||||
coordinator_hourly_forecast: GoogleWeatherHourlyForecastCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoogleWeatherRuntimeData:
|
||||
"""Runtime data for the Google Weather integration."""
|
||||
|
||||
api: GoogleWeatherApi
|
||||
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData]
|
||||
|
||||
|
||||
type GoogleWeatherConfigEntry = ConfigEntry[GoogleWeatherRuntimeData]
|
||||
|
||||
|
||||
class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
|
||||
"""Base class for Google Weather coordinators."""
|
||||
|
||||
config_entry: GoogleWeatherConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GoogleWeatherConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
data_type_name: str,
|
||||
update_interval: timedelta,
|
||||
api_method: Callable[..., Awaitable[T]],
|
||||
) -> None:
|
||||
"""Initialize the data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"Google Weather {data_type_name} coordinator for {subentry.title}",
|
||||
update_interval=update_interval,
|
||||
)
|
||||
self.subentry = subentry
|
||||
self._data_type_name = data_type_name
|
||||
self._api_method = api_method
|
||||
|
||||
async def _async_update_data(self) -> T:
|
||||
"""Fetch data from API and handle errors."""
|
||||
try:
|
||||
return await self._api_method(
|
||||
self.subentry.data[CONF_LATITUDE],
|
||||
self.subentry.data[CONF_LONGITUDE],
|
||||
)
|
||||
except GoogleWeatherApiError as err:
|
||||
_LOGGER.error(
|
||||
"Error fetching %s for %s: %s",
|
||||
self._data_type_name,
|
||||
self.subentry.title,
|
||||
err,
|
||||
)
|
||||
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err
|
||||
|
||||
|
||||
class GoogleWeatherCurrentConditionsCoordinator(
|
||||
GoogleWeatherBaseCoordinator[CurrentConditionsResponse]
|
||||
):
|
||||
"""Handle fetching current weather conditions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GoogleWeatherConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
api: GoogleWeatherApi,
|
||||
) -> None:
|
||||
"""Initialize the data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
config_entry,
|
||||
subentry,
|
||||
"current weather conditions",
|
||||
timedelta(minutes=15),
|
||||
api.async_get_current_conditions,
|
||||
)
|
||||
|
||||
|
||||
class GoogleWeatherDailyForecastCoordinator(
|
||||
GoogleWeatherBaseCoordinator[DailyForecastResponse]
|
||||
):
|
||||
"""Handle fetching daily weather forecast."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GoogleWeatherConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
api: GoogleWeatherApi,
|
||||
) -> None:
|
||||
"""Initialize the data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
config_entry,
|
||||
subentry,
|
||||
"daily weather forecast",
|
||||
timedelta(hours=1),
|
||||
api.async_get_daily_forecast,
|
||||
)
|
||||
|
||||
|
||||
class GoogleWeatherHourlyForecastCoordinator(
|
||||
GoogleWeatherBaseCoordinator[HourlyForecastResponse]
|
||||
):
|
||||
"""Handle fetching hourly weather forecast."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GoogleWeatherConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
api: GoogleWeatherApi,
|
||||
) -> None:
|
||||
"""Initialize the data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
config_entry,
|
||||
subentry,
|
||||
"hourly weather forecast",
|
||||
timedelta(hours=1),
|
||||
api.async_get_hourly_forecast,
|
||||
)
|
||||
28
homeassistant/components/google_weather/entity.py
Normal file
28
homeassistant/components/google_weather/entity.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Base entity for Google Weather."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GoogleWeatherConfigEntry
|
||||
|
||||
|
||||
class GoogleWeatherBaseEntity(Entity):
|
||||
"""Base entity for all Google Weather entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, config_entry: GoogleWeatherConfigEntry, subentry: ConfigSubentry
|
||||
) -> None:
|
||||
"""Initialize base entity."""
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Google",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
12
homeassistant/components/google_weather/manifest.json
Normal file
12
homeassistant/components/google_weather/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "google_weather",
|
||||
"name": "Google Weather",
|
||||
"codeowners": ["@tronikos"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_weather",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google_weather_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-google-weather-api==0.0.4"]
|
||||
}
|
||||
82
homeassistant/components/google_weather/quality_scale.yaml
Normal file
82
homeassistant/components/google_weather/quality_scale.yaml
Normal file
@@ -0,0 +1,82 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No actions.
|
||||
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 actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: No events subscribed.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: No actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: No configuration options.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: No discovery.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: No physical devices.
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: N/A
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repairs.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: N/A
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
65
homeassistant/components/google_weather/strings.json
Normal file
65
homeassistant/components/google_weather/strings.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "A unique alphanumeric string that associates your Google billing account with Google Weather API",
|
||||
"location": "Location coordinates",
|
||||
"name": "Location name"
|
||||
},
|
||||
"description": "Get your API key from [here]({api_key_url}).",
|
||||
"sections": {
|
||||
"api_key_options": {
|
||||
"data": {
|
||||
"referrer": "HTTP referrer"
|
||||
},
|
||||
"data_description": {
|
||||
"referrer": "Specify this only if the API key has a [website application restriction]({restricting_api_keys_url})"
|
||||
},
|
||||
"name": "Optional API key options"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"location": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
|
||||
"entry_not_loaded": "Cannot add things while the configuration is disabled."
|
||||
},
|
||||
"entry_type": "Location",
|
||||
"error": {
|
||||
"cannot_connect": "[%key:component::google_weather::config::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add location"
|
||||
},
|
||||
"step": {
|
||||
"location": {
|
||||
"data": {
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"location": "[%key:component::google_weather::config::step::user::data_description::location%]",
|
||||
"name": "[%key:component::google_weather::config::step::user::data_description::name%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
366
homeassistant/components/google_weather/weather.py
Normal file
366
homeassistant/components/google_weather/weather.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""Weather entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google_weather_api import (
|
||||
DailyForecastResponse,
|
||||
HourlyForecastResponse,
|
||||
WeatherCondition,
|
||||
)
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_CONDITION_CLEAR_NIGHT,
|
||||
ATTR_CONDITION_CLOUDY,
|
||||
ATTR_CONDITION_HAIL,
|
||||
ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
ATTR_CONDITION_PARTLYCLOUDY,
|
||||
ATTR_CONDITION_POURING,
|
||||
ATTR_CONDITION_RAINY,
|
||||
ATTR_CONDITION_SNOWY,
|
||||
ATTR_CONDITION_SNOWY_RAINY,
|
||||
ATTR_CONDITION_SUNNY,
|
||||
ATTR_CONDITION_WINDY,
|
||||
ATTR_FORECAST_CLOUD_COVERAGE,
|
||||
ATTR_FORECAST_CONDITION,
|
||||
ATTR_FORECAST_HUMIDITY,
|
||||
ATTR_FORECAST_IS_DAYTIME,
|
||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP,
|
||||
ATTR_FORECAST_NATIVE_DEW_POINT,
|
||||
ATTR_FORECAST_NATIVE_PRECIPITATION,
|
||||
ATTR_FORECAST_NATIVE_PRESSURE,
|
||||
ATTR_FORECAST_NATIVE_TEMP,
|
||||
ATTR_FORECAST_NATIVE_TEMP_LOW,
|
||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
|
||||
ATTR_FORECAST_TIME,
|
||||
ATTR_FORECAST_UV_INDEX,
|
||||
ATTR_FORECAST_WIND_BEARING,
|
||||
CoordinatorWeatherEntity,
|
||||
Forecast,
|
||||
WeatherEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import (
|
||||
UnitOfLength,
|
||||
UnitOfPrecipitationDepth,
|
||||
UnitOfPressure,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import (
|
||||
GoogleWeatherConfigEntry,
|
||||
GoogleWeatherCurrentConditionsCoordinator,
|
||||
GoogleWeatherDailyForecastCoordinator,
|
||||
GoogleWeatherHourlyForecastCoordinator,
|
||||
)
|
||||
from .entity import GoogleWeatherBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
# Maps https://developers.google.com/maps/documentation/weather/weather-condition-icons
|
||||
# to https://developers.home-assistant.io/docs/core/entity/weather/#recommended-values-for-state-and-condition
|
||||
_CONDITION_MAP: dict[WeatherCondition.Type, str | None] = {
|
||||
WeatherCondition.Type.TYPE_UNSPECIFIED: None,
|
||||
WeatherCondition.Type.CLEAR: ATTR_CONDITION_SUNNY,
|
||||
WeatherCondition.Type.MOSTLY_CLEAR: ATTR_CONDITION_PARTLYCLOUDY,
|
||||
WeatherCondition.Type.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY,
|
||||
WeatherCondition.Type.MOSTLY_CLOUDY: ATTR_CONDITION_CLOUDY,
|
||||
WeatherCondition.Type.CLOUDY: ATTR_CONDITION_CLOUDY,
|
||||
WeatherCondition.Type.WINDY: ATTR_CONDITION_WINDY,
|
||||
WeatherCondition.Type.WIND_AND_RAIN: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.LIGHT_RAIN_SHOWERS: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.CHANCE_OF_SHOWERS: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.SCATTERED_SHOWERS: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.RAIN_SHOWERS: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.HEAVY_RAIN_SHOWERS: ATTR_CONDITION_POURING,
|
||||
WeatherCondition.Type.LIGHT_TO_MODERATE_RAIN: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.MODERATE_TO_HEAVY_RAIN: ATTR_CONDITION_POURING,
|
||||
WeatherCondition.Type.RAIN: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.LIGHT_RAIN: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.HEAVY_RAIN: ATTR_CONDITION_POURING,
|
||||
WeatherCondition.Type.RAIN_PERIODICALLY_HEAVY: ATTR_CONDITION_POURING,
|
||||
WeatherCondition.Type.LIGHT_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.CHANCE_OF_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.SCATTERED_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.HEAVY_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.LIGHT_TO_MODERATE_SNOW: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.MODERATE_TO_HEAVY_SNOW: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.SNOW: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.LIGHT_SNOW: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.HEAVY_SNOW: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.SNOWSTORM: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.SNOW_PERIODICALLY_HEAVY: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.HEAVY_SNOW_STORM: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.BLOWING_SNOW: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.RAIN_AND_SNOW: ATTR_CONDITION_SNOWY_RAINY,
|
||||
WeatherCondition.Type.HAIL: ATTR_CONDITION_HAIL,
|
||||
WeatherCondition.Type.HAIL_SHOWERS: ATTR_CONDITION_HAIL,
|
||||
WeatherCondition.Type.THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
WeatherCondition.Type.THUNDERSHOWER: ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
WeatherCondition.Type.LIGHT_THUNDERSTORM_RAIN: ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
WeatherCondition.Type.SCATTERED_THUNDERSTORMS: ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
WeatherCondition.Type.HEAVY_THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
}
|
||||
|
||||
|
||||
def _get_condition(
|
||||
api_condition: WeatherCondition.Type, is_daytime: bool
|
||||
) -> str | None:
|
||||
"""Map Google Weather condition to Home Assistant condition."""
|
||||
cond = _CONDITION_MAP[api_condition]
|
||||
if cond == ATTR_CONDITION_SUNNY and not is_daytime:
|
||||
return ATTR_CONDITION_CLEAR_NIGHT
|
||||
return cond
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GoogleWeatherConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add a weather entity from a config_entry."""
|
||||
for subentry in entry.subentries.values():
|
||||
async_add_entities(
|
||||
[GoogleWeatherEntity(entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class GoogleWeatherEntity(
|
||||
CoordinatorWeatherEntity[
|
||||
GoogleWeatherCurrentConditionsCoordinator,
|
||||
GoogleWeatherDailyForecastCoordinator,
|
||||
GoogleWeatherHourlyForecastCoordinator,
|
||||
GoogleWeatherDailyForecastCoordinator,
|
||||
],
|
||||
GoogleWeatherBaseEntity,
|
||||
):
|
||||
"""Representation of a Google Weather entity."""
|
||||
|
||||
_attr_attribution = "Data from Google Weather"
|
||||
|
||||
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_native_pressure_unit = UnitOfPressure.MBAR
|
||||
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
|
||||
_attr_native_visibility_unit = UnitOfLength.KILOMETERS
|
||||
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
|
||||
|
||||
_attr_name = None
|
||||
|
||||
_attr_supported_features = (
|
||||
WeatherEntityFeature.FORECAST_DAILY
|
||||
| WeatherEntityFeature.FORECAST_HOURLY
|
||||
| WeatherEntityFeature.FORECAST_TWICE_DAILY
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: GoogleWeatherConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
"""Initialize the weather entity."""
|
||||
subentry_runtime_data = entry.runtime_data.subentries_runtime_data[
|
||||
subentry.subentry_id
|
||||
]
|
||||
super().__init__(
|
||||
observation_coordinator=subentry_runtime_data.coordinator_observation,
|
||||
daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
|
||||
hourly_coordinator=subentry_runtime_data.coordinator_hourly_forecast,
|
||||
twice_daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
|
||||
)
|
||||
GoogleWeatherBaseEntity.__init__(self, entry, subentry)
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
"""Return the current condition."""
|
||||
return _get_condition(
|
||||
self.coordinator.data.weather_condition.type,
|
||||
self.coordinator.data.is_daytime,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_temperature(self) -> float:
|
||||
"""Return the temperature."""
|
||||
return self.coordinator.data.temperature.degrees
|
||||
|
||||
@property
|
||||
def native_apparent_temperature(self) -> float:
|
||||
"""Return the apparent temperature."""
|
||||
return self.coordinator.data.feels_like_temperature.degrees
|
||||
|
||||
@property
|
||||
def native_dew_point(self) -> float:
|
||||
"""Return the dew point."""
|
||||
return self.coordinator.data.dew_point.degrees
|
||||
|
||||
@property
|
||||
def humidity(self) -> int:
|
||||
"""Return the humidity."""
|
||||
return self.coordinator.data.relative_humidity
|
||||
|
||||
@property
|
||||
def uv_index(self) -> float:
|
||||
"""Return the UV index."""
|
||||
return float(self.coordinator.data.uv_index)
|
||||
|
||||
@property
|
||||
def native_pressure(self) -> float:
|
||||
"""Return the pressure."""
|
||||
return self.coordinator.data.air_pressure.mean_sea_level_millibars
|
||||
|
||||
@property
|
||||
def native_wind_gust_speed(self) -> float:
|
||||
"""Return the wind gust speed."""
|
||||
return self.coordinator.data.wind.gust.value
|
||||
|
||||
@property
|
||||
def native_wind_speed(self) -> float:
|
||||
"""Return the wind speed."""
|
||||
return self.coordinator.data.wind.speed.value
|
||||
|
||||
@property
|
||||
def wind_bearing(self) -> int:
|
||||
"""Return the wind bearing."""
|
||||
return self.coordinator.data.wind.direction.degrees
|
||||
|
||||
@property
|
||||
def native_visibility(self) -> float:
|
||||
"""Return the visibility."""
|
||||
return self.coordinator.data.visibility.distance
|
||||
|
||||
@property
|
||||
def cloud_coverage(self) -> float:
|
||||
"""Return the Cloud coverage in %."""
|
||||
return float(self.coordinator.data.cloud_cover)
|
||||
|
||||
@callback
|
||||
def _async_forecast_daily(self) -> list[Forecast] | None:
|
||||
"""Return the daily forecast in native units."""
|
||||
coordinator = self.forecast_coordinators["daily"]
|
||||
assert coordinator
|
||||
daily_data = coordinator.data
|
||||
assert isinstance(daily_data, DailyForecastResponse)
|
||||
return [
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: _get_condition(
|
||||
item.daytime_forecast.weather_condition.type, is_daytime=True
|
||||
),
|
||||
ATTR_FORECAST_TIME: item.interval.start_time,
|
||||
ATTR_FORECAST_HUMIDITY: item.daytime_forecast.relative_humidity,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: max(
|
||||
item.daytime_forecast.precipitation.probability.percent,
|
||||
item.nighttime_forecast.precipitation.probability.percent,
|
||||
),
|
||||
ATTR_FORECAST_CLOUD_COVERAGE: item.daytime_forecast.cloud_cover,
|
||||
ATTR_FORECAST_NATIVE_PRECIPITATION: (
|
||||
item.daytime_forecast.precipitation.qpf.quantity
|
||||
+ item.nighttime_forecast.precipitation.qpf.quantity
|
||||
),
|
||||
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
|
||||
ATTR_FORECAST_NATIVE_TEMP_LOW: item.min_temperature.degrees,
|
||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: (
|
||||
item.feels_like_max_temperature.degrees
|
||||
),
|
||||
ATTR_FORECAST_WIND_BEARING: item.daytime_forecast.wind.direction.degrees,
|
||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: max(
|
||||
item.daytime_forecast.wind.gust.value,
|
||||
item.nighttime_forecast.wind.gust.value,
|
||||
),
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED: max(
|
||||
item.daytime_forecast.wind.speed.value,
|
||||
item.nighttime_forecast.wind.speed.value,
|
||||
),
|
||||
ATTR_FORECAST_UV_INDEX: item.daytime_forecast.uv_index,
|
||||
}
|
||||
for item in daily_data.forecast_days
|
||||
]
|
||||
|
||||
@callback
|
||||
def _async_forecast_hourly(self) -> list[Forecast] | None:
|
||||
"""Return the hourly forecast in native units."""
|
||||
coordinator = self.forecast_coordinators["hourly"]
|
||||
assert coordinator
|
||||
hourly_data = coordinator.data
|
||||
assert isinstance(hourly_data, HourlyForecastResponse)
|
||||
return [
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: _get_condition(
|
||||
item.weather_condition.type, item.is_daytime
|
||||
),
|
||||
ATTR_FORECAST_TIME: item.interval.start_time,
|
||||
ATTR_FORECAST_HUMIDITY: item.relative_humidity,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: item.precipitation.probability.percent,
|
||||
ATTR_FORECAST_CLOUD_COVERAGE: item.cloud_cover,
|
||||
ATTR_FORECAST_NATIVE_PRECIPITATION: item.precipitation.qpf.quantity,
|
||||
ATTR_FORECAST_NATIVE_PRESSURE: item.air_pressure.mean_sea_level_millibars,
|
||||
ATTR_FORECAST_NATIVE_TEMP: item.temperature.degrees,
|
||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_temperature.degrees,
|
||||
ATTR_FORECAST_WIND_BEARING: item.wind.direction.degrees,
|
||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item.wind.gust.value,
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED: item.wind.speed.value,
|
||||
ATTR_FORECAST_NATIVE_DEW_POINT: item.dew_point.degrees,
|
||||
ATTR_FORECAST_UV_INDEX: item.uv_index,
|
||||
ATTR_FORECAST_IS_DAYTIME: item.is_daytime,
|
||||
}
|
||||
for item in hourly_data.forecast_hours
|
||||
]
|
||||
|
||||
@callback
|
||||
def _async_forecast_twice_daily(self) -> list[Forecast] | None:
|
||||
"""Return the twice daily forecast in native units."""
|
||||
coordinator = self.forecast_coordinators["twice_daily"]
|
||||
assert coordinator
|
||||
daily_data = coordinator.data
|
||||
assert isinstance(daily_data, DailyForecastResponse)
|
||||
forecasts: list[Forecast] = []
|
||||
for item in daily_data.forecast_days:
|
||||
# Process daytime forecast
|
||||
day_forecast = item.daytime_forecast
|
||||
forecasts.append(
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: _get_condition(
|
||||
day_forecast.weather_condition.type, is_daytime=True
|
||||
),
|
||||
ATTR_FORECAST_TIME: day_forecast.interval.start_time,
|
||||
ATTR_FORECAST_HUMIDITY: day_forecast.relative_humidity,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: day_forecast.precipitation.probability.percent,
|
||||
ATTR_FORECAST_CLOUD_COVERAGE: day_forecast.cloud_cover,
|
||||
ATTR_FORECAST_NATIVE_PRECIPITATION: day_forecast.precipitation.qpf.quantity,
|
||||
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
|
||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_max_temperature.degrees,
|
||||
ATTR_FORECAST_WIND_BEARING: day_forecast.wind.direction.degrees,
|
||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: day_forecast.wind.gust.value,
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED: day_forecast.wind.speed.value,
|
||||
ATTR_FORECAST_UV_INDEX: day_forecast.uv_index,
|
||||
ATTR_FORECAST_IS_DAYTIME: True,
|
||||
}
|
||||
)
|
||||
|
||||
# Process nighttime forecast
|
||||
night_forecast = item.nighttime_forecast
|
||||
forecasts.append(
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: _get_condition(
|
||||
night_forecast.weather_condition.type, is_daytime=False
|
||||
),
|
||||
ATTR_FORECAST_TIME: night_forecast.interval.start_time,
|
||||
ATTR_FORECAST_HUMIDITY: night_forecast.relative_humidity,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: night_forecast.precipitation.probability.percent,
|
||||
ATTR_FORECAST_CLOUD_COVERAGE: night_forecast.cloud_cover,
|
||||
ATTR_FORECAST_NATIVE_PRECIPITATION: night_forecast.precipitation.qpf.quantity,
|
||||
ATTR_FORECAST_NATIVE_TEMP: item.min_temperature.degrees,
|
||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_min_temperature.degrees,
|
||||
ATTR_FORECAST_WIND_BEARING: night_forecast.wind.direction.degrees,
|
||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: night_forecast.wind.gust.value,
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED: night_forecast.wind.speed.value,
|
||||
ATTR_FORECAST_UV_INDEX: night_forecast.uv_index,
|
||||
ATTR_FORECAST_IS_DAYTIME: False,
|
||||
}
|
||||
)
|
||||
|
||||
return forecasts
|
||||
@@ -1,77 +0,0 @@
|
||||
"""Support for the Hive alarm."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntity,
|
||||
AlarmControlPanelEntityFeature,
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HiveConfigEntry
|
||||
from .entity import HiveEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
HIVETOHA = {
|
||||
"home": AlarmControlPanelState.DISARMED,
|
||||
"asleep": AlarmControlPanelState.ARMED_NIGHT,
|
||||
"away": AlarmControlPanelState.ARMED_AWAY,
|
||||
"sos": AlarmControlPanelState.TRIGGERED,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HiveConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Hive thermostat based on a config entry."""
|
||||
|
||||
hive = entry.runtime_data
|
||||
if devices := hive.session.deviceList.get("alarm_control_panel"):
|
||||
async_add_entities(
|
||||
[HiveAlarmControlPanelEntity(hive, dev) for dev in devices], True
|
||||
)
|
||||
|
||||
|
||||
class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity):
|
||||
"""Representation of a Hive alarm."""
|
||||
|
||||
_attr_supported_features = (
|
||||
AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
| AlarmControlPanelEntityFeature.TRIGGER
|
||||
)
|
||||
_attr_code_arm_required = False
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
await self.hive.alarm.setMode(self.device, "home")
|
||||
|
||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||
"""Send arm night command."""
|
||||
await self.hive.alarm.setMode(self.device, "asleep")
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
await self.hive.alarm.setMode(self.device, "away")
|
||||
|
||||
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
||||
"""Send alarm trigger command."""
|
||||
await self.hive.alarm.setMode(self.device, "sos")
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update all Node data from Hive."""
|
||||
await self.hive.session.updateData(self.device)
|
||||
self.device = await self.hive.alarm.getAlarm(self.device)
|
||||
self._attr_available = self.device["deviceData"].get("online")
|
||||
if self._attr_available:
|
||||
if self.device["status"]["state"]:
|
||||
self._attr_alarm_state = AlarmControlPanelState.TRIGGERED
|
||||
else:
|
||||
self._attr_alarm_state = HIVETOHA[self.device["status"]["mode"]]
|
||||
@@ -11,7 +11,6 @@ CONFIG_ENTRY_VERSION = 1
|
||||
DEFAULT_NAME = "Hive"
|
||||
DOMAIN = "hive"
|
||||
PLATFORMS = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
@@ -20,7 +19,6 @@ PLATFORMS = [
|
||||
Platform.WATER_HEATER,
|
||||
]
|
||||
PLATFORM_LOOKUP = {
|
||||
Platform.ALARM_CONTROL_PANEL: "alarm_control_panel",
|
||||
Platform.BINARY_SENSOR: "binary_sensor",
|
||||
Platform.CLIMATE: "climate",
|
||||
Platform.LIGHT: "light",
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
},
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["apyhiveapi"],
|
||||
"requirements": ["pyhive-integration==1.0.6"]
|
||||
"requirements": ["pyhive-integration==1.0.7"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
from aiohomeconnect.model import (
|
||||
@@ -247,14 +247,15 @@ class HomeConnectCoordinator(
|
||||
value=event.value,
|
||||
)
|
||||
else:
|
||||
event_value = event.value
|
||||
if event_key in (
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
):
|
||||
) and isinstance(event_value, str):
|
||||
await self.update_options(
|
||||
event_message_ha_id,
|
||||
event_key,
|
||||
ProgramKey(cast(str, event.value)),
|
||||
ProgramKey(event_value),
|
||||
)
|
||||
events[event_key] = event
|
||||
self._call_event_listener(event_message)
|
||||
|
||||
@@ -14,7 +14,6 @@ from aiohomeconnect.model.error import (
|
||||
TooManyRequestsError,
|
||||
)
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -62,10 +61,8 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.update_native_value()
|
||||
available = self._attr_available = self.appliance.info.connected
|
||||
self.async_write_ha_state()
|
||||
state = STATE_UNAVAILABLE if not available else self.state
|
||||
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, state)
|
||||
_LOGGER.debug("Updated %s", self)
|
||||
|
||||
@property
|
||||
def bsh_key(self) -> str:
|
||||
@@ -80,7 +77,7 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
||||
as event updates should take precedence over the coordinator
|
||||
refresh.
|
||||
"""
|
||||
return self._attr_available
|
||||
return self.appliance.info.connected and self._attr_available
|
||||
|
||||
|
||||
class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.23.0"],
|
||||
"requirements": ["aiohomeconnect==0.23.1"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -412,8 +412,8 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
"""Set the program value."""
|
||||
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
|
||||
self._attr_current_option = (
|
||||
PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value))
|
||||
if event
|
||||
PROGRAMS_TRANSLATION_KEYS_MAP.get(ProgramKey(event_value))
|
||||
if event and isinstance(event_value := event.value, str)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
@@ -556,8 +556,11 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
||||
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
|
||||
self._update_native_value(status)
|
||||
|
||||
def _update_native_value(self, status: str | float) -> None:
|
||||
def _update_native_value(self, status: str | float | None) -> None:
|
||||
"""Set the value of the sensor based on the given value."""
|
||||
if status is None:
|
||||
self._attr_native_value = None
|
||||
return
|
||||
match self.device_class:
|
||||
case SensorDeviceClass.TIMESTAMP:
|
||||
self._attr_native_value = dt_util.utcnow() + timedelta(
|
||||
|
||||
@@ -1237,7 +1237,7 @@
|
||||
"message": "Error obtaining data from the API: {error}"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation temporarily unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"pause_program": {
|
||||
"message": "Error pausing program: {error}"
|
||||
|
||||
@@ -76,9 +76,18 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
|
||||
|
||||
context: ConfigFlowContext
|
||||
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
|
||||
|
||||
ZIGBEE_BAUDRATE = 460800
|
||||
|
||||
# Early ZBT-2 samples used RTS/DTR to trigger the bootloader, later ones use the
|
||||
# baudrate method. Since the two are mutually exclusive we just use both.
|
||||
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
|
||||
APPLICATION_PROBE_METHODS = [
|
||||
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||
(ApplicationType.SPINEL, 460800),
|
||||
]
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2",
|
||||
"integration_type": "hardware",
|
||||
"loggers": [
|
||||
"bellows",
|
||||
"universal_silabs_flasher",
|
||||
"zigpy.serial",
|
||||
"serial_asyncio_fast"
|
||||
],
|
||||
"quality_scale": "bronze",
|
||||
"usb": [
|
||||
{
|
||||
|
||||
@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.components.update import UpdateDeviceClass
|
||||
from homeassistant.const import EntityCategory
|
||||
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeAssistantConnectZBT2ConfigEntry
|
||||
from .config_flow import ZBT2FirmwareMixin
|
||||
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -134,7 +134,8 @@ async def async_setup_entry(
|
||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""Connect ZBT-2 firmware update entity."""
|
||||
|
||||
bootloader_reset_methods = [ResetTarget.RTS_DTR]
|
||||
BOOTLOADER_RESET_METHODS = ZBT2FirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||
APPLICATION_PROBE_METHODS = ZBT2FirmwareMixin.APPLICATION_PROBE_METHODS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -81,6 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
|
||||
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]] = []
|
||||
|
||||
_picked_firmware_type: PickedFirmwareType
|
||||
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
||||
@@ -230,7 +231,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
# Installing new firmware is only truly required if the wrong type is
|
||||
# installed: upgrading to the latest release of the current firmware type
|
||||
# isn't strictly necessary for functionality.
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||
self._device,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
)
|
||||
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
||||
@@ -295,6 +300,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
progress_callback=lambda offset, total: self.async_update_progress(
|
||||
offset / total
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||
"integration_type": "system",
|
||||
"requirements": [
|
||||
"universal-silabs-flasher==0.0.37",
|
||||
"universal-silabs-flasher==0.1.0",
|
||||
"ha-silabs-firmware-client==0.3.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@ class BaseFirmwareUpdateEntity(
|
||||
|
||||
# Subclasses provide the mapping between firmware types and entity descriptions
|
||||
entity_description: FirmwareUpdateEntityDescription
|
||||
bootloader_reset_methods: list[ResetTarget] = []
|
||||
BOOTLOADER_RESET_METHODS: list[ResetTarget]
|
||||
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]]
|
||||
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
@@ -278,7 +279,8 @@ class BaseFirmwareUpdateEntity(
|
||||
device=self._current_device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
||||
bootloader_reset_methods=self.bootloader_reset_methods,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
progress_callback=self._update_progress,
|
||||
domain=self._config_entry.domain,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
||||
from collections.abc import AsyncIterator, Callable, Sequence
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
@@ -309,15 +309,20 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
|
||||
|
||||
|
||||
async def probe_silabs_firmware_info(
|
||||
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
|
||||
device: str,
|
||||
*,
|
||||
bootloader_reset_methods: Sequence[ResetTarget],
|
||||
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||
) -> FirmwareInfo | None:
|
||||
"""Probe the running firmware on a SiLabs device."""
|
||||
flasher = Flasher(
|
||||
device=device,
|
||||
**(
|
||||
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]}
|
||||
if probe_methods
|
||||
else {}
|
||||
probe_methods=tuple(
|
||||
(m.as_flasher_application_type(), baudrate)
|
||||
for m, baudrate in application_probe_methods
|
||||
),
|
||||
bootloader_reset=tuple(
|
||||
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||
),
|
||||
)
|
||||
|
||||
@@ -343,11 +348,18 @@ async def probe_silabs_firmware_info(
|
||||
|
||||
|
||||
async def probe_silabs_firmware_type(
|
||||
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
|
||||
device: str,
|
||||
*,
|
||||
bootloader_reset_methods: Sequence[ResetTarget],
|
||||
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||
) -> ApplicationType | None:
|
||||
"""Probe the running firmware type on a SiLabs device."""
|
||||
|
||||
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods)
|
||||
fw_info = await probe_silabs_firmware_info(
|
||||
device,
|
||||
bootloader_reset_methods=bootloader_reset_methods,
|
||||
application_probe_methods=application_probe_methods,
|
||||
)
|
||||
if fw_info is None:
|
||||
return None
|
||||
|
||||
@@ -359,12 +371,22 @@ async def async_flash_silabs_firmware(
|
||||
device: str,
|
||||
fw_data: bytes,
|
||||
expected_installed_firmware_type: ApplicationType,
|
||||
bootloader_reset_methods: Sequence[ResetTarget] = (),
|
||||
bootloader_reset_methods: Sequence[ResetTarget],
|
||||
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||
progress_callback: Callable[[int, int], None] | None = None,
|
||||
*,
|
||||
domain: str = DOMAIN,
|
||||
) -> FirmwareInfo:
|
||||
"""Flash firmware to the SiLabs device."""
|
||||
if not any(
|
||||
method == expected_installed_firmware_type
|
||||
for method, _ in application_probe_methods
|
||||
):
|
||||
raise ValueError(
|
||||
f"Expected installed firmware type {expected_installed_firmware_type!r}"
|
||||
f" not in application probe methods {application_probe_methods!r}"
|
||||
)
|
||||
|
||||
async with async_firmware_update_context(hass, device, domain):
|
||||
firmware_info = await guess_firmware_info(hass, device)
|
||||
_LOGGER.debug("Identified firmware info: %s", firmware_info)
|
||||
@@ -373,11 +395,9 @@ async def async_flash_silabs_firmware(
|
||||
|
||||
flasher = Flasher(
|
||||
device=device,
|
||||
probe_methods=(
|
||||
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
|
||||
ApplicationType.EZSP.as_flasher_application_type(),
|
||||
ApplicationType.SPINEL.as_flasher_application_type(),
|
||||
ApplicationType.CPC.as_flasher_application_type(),
|
||||
probe_methods=tuple(
|
||||
(m.as_flasher_application_type(), baudrate)
|
||||
for m, baudrate in application_probe_methods
|
||||
),
|
||||
bootloader_reset=tuple(
|
||||
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||
@@ -401,7 +421,13 @@ async def async_flash_silabs_firmware(
|
||||
|
||||
probed_firmware_info = await probe_silabs_firmware_info(
|
||||
device,
|
||||
probe_methods=(expected_installed_firmware_type,),
|
||||
bootloader_reset_methods=bootloader_reset_methods,
|
||||
# Only probe for the expected installed firmware type
|
||||
application_probe_methods=[
|
||||
(method, baudrate)
|
||||
for method, baudrate in application_probe_methods
|
||||
if method == expected_installed_firmware_type
|
||||
],
|
||||
)
|
||||
|
||||
if probed_firmware_info is None:
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.helpers import (
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.components.usb import (
|
||||
usb_service_info_from_device,
|
||||
@@ -79,6 +80,20 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
|
||||
context: ConfigFlowContext
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200
|
||||
# There is no hardware bootloader trigger
|
||||
BOOTLOADER_RESET_METHODS: list[ResetTarget] = []
|
||||
APPLICATION_PROBE_METHODS = [
|
||||
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||
(ApplicationType.SPINEL, 460800),
|
||||
# CPC baudrates can be removed once multiprotocol is removed
|
||||
(ApplicationType.CPC, 115200),
|
||||
(ApplicationType.CPC, 230400),
|
||||
(ApplicationType.CPC, 460800),
|
||||
(ApplicationType.ROUTER, 115200),
|
||||
]
|
||||
|
||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||
"""Shared translation placeholders."""
|
||||
placeholders = {
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect",
|
||||
"integration_type": "hardware",
|
||||
"loggers": [
|
||||
"bellows",
|
||||
"universal_silabs_flasher",
|
||||
"zigpy.serial",
|
||||
"serial_asyncio_fast"
|
||||
],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*skyconnect v1.0*",
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeAssistantSkyConnectConfigEntry
|
||||
from .config_flow import SkyConnectFirmwareMixin
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FIRMWARE,
|
||||
@@ -151,8 +152,8 @@ async def async_setup_entry(
|
||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""SkyConnect firmware update entity."""
|
||||
|
||||
# The ZBT-1 does not have a hardware bootloader trigger
|
||||
bootloader_reset_methods = []
|
||||
BOOTLOADER_RESET_METHODS = SkyConnectFirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||
APPLICATION_PROBE_METHODS = SkyConnectFirmwareMixin.APPLICATION_PROBE_METHODS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -82,7 +82,18 @@ else:
|
||||
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
"""Mixin for Home Assistant Yellow firmware methods."""
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200
|
||||
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
|
||||
APPLICATION_PROBE_METHODS = [
|
||||
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||
(ApplicationType.SPINEL, 460800),
|
||||
# CPC baudrates can be removed once multiprotocol is removed
|
||||
(ApplicationType.CPC, 115200),
|
||||
(ApplicationType.CPC, 230400),
|
||||
(ApplicationType.CPC, 460800),
|
||||
(ApplicationType.ROUTER, 115200),
|
||||
]
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -146,7 +157,11 @@ class HomeAssistantYellowConfigFlow(
|
||||
assert self._device is not None
|
||||
|
||||
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||
self._device,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
)
|
||||
|
||||
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
||||
if (
|
||||
|
||||
@@ -7,5 +7,11 @@
|
||||
"dependencies": ["hardware", "homeassistant_hardware"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow",
|
||||
"integration_type": "hardware",
|
||||
"loggers": [
|
||||
"bellows",
|
||||
"universal_silabs_flasher",
|
||||
"zigpy.serial",
|
||||
"serial_asyncio_fast"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.components.update import UpdateDeviceClass
|
||||
from homeassistant.const import EntityCategory
|
||||
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeAssistantYellowConfigEntry
|
||||
from .config_flow import YellowFirmwareMixin
|
||||
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -150,7 +150,8 @@ async def async_setup_entry(
|
||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""Yellow firmware update entity."""
|
||||
|
||||
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
|
||||
BOOTLOADER_RESET_METHODS = YellowFirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||
APPLICATION_PROBE_METHODS = YellowFirmwareMixin.APPLICATION_PROBE_METHODS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiopvapi.resources.model import PowerviewData
|
||||
from aiopvapi.resources.shade_data import PowerviewShadeData
|
||||
from aiopvapi.rooms import Rooms
|
||||
from aiopvapi.scenes import Scenes
|
||||
from aiopvapi.shades import Shades
|
||||
@@ -16,7 +17,6 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER
|
||||
from .coordinator import PowerviewShadeUpdateCoordinator
|
||||
from .model import PowerviewConfigEntry, PowerviewEntryData
|
||||
from .shade_data import PowerviewShadeData
|
||||
from .util import async_connect_hub
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
|
||||
from aiopvapi.helpers.aiorequest import PvApiMaintenance
|
||||
from aiopvapi.hub import Hub
|
||||
from aiopvapi.resources.shade_data import PowerviewShadeData
|
||||
from aiopvapi.shades import Shades
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -15,7 +16,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import HUB_EXCEPTIONS
|
||||
from .shade_data import PowerviewShadeData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -208,13 +208,13 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
|
||||
async def _async_execute_move(self, move: ShadePosition) -> None:
|
||||
"""Execute a move that can affect multiple positions."""
|
||||
_LOGGER.debug("Move request %s: %s", self.name, move)
|
||||
# Store the requested positions so subsequent move
|
||||
# requests contain the secondary shade positions
|
||||
self.data.update_shade_position(self._shade.id, move)
|
||||
async with self.coordinator.radio_operation_lock:
|
||||
response = await self._shade.move(move)
|
||||
_LOGGER.debug("Move response %s: %s", self.name, response)
|
||||
|
||||
# Process the response from the hub (including new positions)
|
||||
self.data.update_shade_position(self._shade.id, response)
|
||||
|
||||
async def _async_set_cover_position(self, target_hass_position: int) -> None:
|
||||
"""Move the shade to a position."""
|
||||
target_hass_position = self._clamp_cover_limit(target_hass_position)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
|
||||
from aiopvapi.resources.shade import BaseShade, ShadePosition
|
||||
from aiopvapi.resources.shade_data import PowerviewShadeData
|
||||
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -11,7 +12,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .coordinator import PowerviewShadeUpdateCoordinator
|
||||
from .model import PowerviewDeviceInfo
|
||||
from .shade_data import PowerviewShadeData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
},
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiopvapi"],
|
||||
"requirements": ["aiopvapi==3.2.1"],
|
||||
"requirements": ["aiopvapi==3.3.0"],
|
||||
"zeroconf": ["_powerview._tcp.local.", "_PowerView-G3._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Shade data for the Hunter Douglas PowerView integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import fields
|
||||
from typing import Any
|
||||
|
||||
from aiopvapi.resources.model import PowerviewData
|
||||
from aiopvapi.resources.shade import BaseShade, ShadePosition
|
||||
|
||||
from .util import async_map_data_by_id
|
||||
|
||||
POSITION_FIELDS = [field for field in fields(ShadePosition) if field.name != "velocity"]
|
||||
|
||||
|
||||
def copy_position_data(source: ShadePosition, target: ShadePosition) -> ShadePosition:
|
||||
"""Copy position data from source to target for None values only."""
|
||||
for field in POSITION_FIELDS:
|
||||
if (value := getattr(source, field.name)) is not None:
|
||||
setattr(target, field.name, value)
|
||||
|
||||
|
||||
class PowerviewShadeData:
|
||||
"""Coordinate shade data between multiple api calls."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Init the shade data."""
|
||||
self._raw_data_by_id: dict[int, dict[str | int, Any]] = {}
|
||||
self._shade_group_data_by_id: dict[int, BaseShade] = {}
|
||||
self.positions: dict[int, ShadePosition] = {}
|
||||
|
||||
def get_raw_data(self, shade_id: int) -> dict[str | int, Any]:
|
||||
"""Get data for the shade."""
|
||||
return self._raw_data_by_id[shade_id]
|
||||
|
||||
def get_all_raw_data(self) -> dict[int, dict[str | int, Any]]:
|
||||
"""Get data for all shades."""
|
||||
return self._raw_data_by_id
|
||||
|
||||
def get_shade(self, shade_id: int) -> BaseShade:
|
||||
"""Get specific shade from the coordinator."""
|
||||
return self._shade_group_data_by_id[shade_id]
|
||||
|
||||
def get_shade_position(self, shade_id: int) -> ShadePosition:
|
||||
"""Get positions for a shade."""
|
||||
if shade_id not in self.positions:
|
||||
shade_position = ShadePosition()
|
||||
# If we have the group data, use it to populate the initial position
|
||||
if shade := self._shade_group_data_by_id.get(shade_id):
|
||||
copy_position_data(shade.current_position, shade_position)
|
||||
self.positions[shade_id] = shade_position
|
||||
return self.positions[shade_id]
|
||||
|
||||
def update_from_group_data(self, shade_id: int) -> None:
|
||||
"""Process an update from the group data."""
|
||||
data = self._shade_group_data_by_id[shade_id]
|
||||
copy_position_data(data.current_position, self.get_shade_position(data.id))
|
||||
|
||||
def store_group_data(self, shade_data: PowerviewData) -> None:
|
||||
"""Store data from the all shades endpoint.
|
||||
|
||||
This does not update the shades or positions (self.positions)
|
||||
as the data may be stale. update_from_group_data
|
||||
with a shade_id will update a specific shade
|
||||
from the group data.
|
||||
"""
|
||||
self._shade_group_data_by_id = shade_data.processed
|
||||
self._raw_data_by_id = async_map_data_by_id(shade_data.raw)
|
||||
|
||||
def update_shade_position(self, shade_id: int, new_position: ShadePosition) -> None:
|
||||
"""Update a single shades position."""
|
||||
copy_position_data(new_position, self.get_shade_position(shade_id))
|
||||
|
||||
def update_shade_velocity(self, shade_id: int, shade_data: ShadePosition) -> None:
|
||||
"""Update a single shades velocity."""
|
||||
# the hub will always return a velocity of 0 on initial connect,
|
||||
# separate definition to store consistent value in HA
|
||||
# this value is purely driven from HA
|
||||
if shade_data.velocity is not None:
|
||||
self.get_shade_position(shade_id).velocity = shade_data.velocity
|
||||
@@ -2,25 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from aiopvapi.helpers.aiorequest import AioRequest
|
||||
from aiopvapi.helpers.constants import ATTR_ID
|
||||
from aiopvapi.hub import Hub
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .model import PowerviewAPI, PowerviewDeviceInfo
|
||||
|
||||
|
||||
@callback
|
||||
def async_map_data_by_id(data: Iterable[dict[str | int, Any]]):
|
||||
"""Return a dict with the key being the id for a list of entries."""
|
||||
return {entry[ATTR_ID]: entry for entry in data}
|
||||
|
||||
|
||||
async def async_connect_hub(
|
||||
hass: HomeAssistant, address: str, api_version: int | None = None
|
||||
) -> PowerviewAPI:
|
||||
|
||||
@@ -121,12 +121,15 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
|
||||
"""Initialize AutomowerEntity."""
|
||||
super().__init__(coordinator)
|
||||
self.mower_id = mower_id
|
||||
parts = self.mower_attributes.system.model.split(maxsplit=2)
|
||||
model_witout_manufacturer = self.mower_attributes.system.model.removeprefix(
|
||||
"Husqvarna "
|
||||
).removeprefix("HUSQVARNA ")
|
||||
parts = model_witout_manufacturer.split(maxsplit=1)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mower_id)},
|
||||
manufacturer=parts[0],
|
||||
model=parts[1],
|
||||
model_id=parts[2],
|
||||
manufacturer="Husqvarna",
|
||||
model=parts[0].capitalize().removesuffix("®"),
|
||||
model_id=parts[1],
|
||||
name=self.mower_attributes.system.name,
|
||||
serial_number=self.mower_attributes.system.serial_number,
|
||||
suggested_area="Garden",
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.7.0"]
|
||||
"requirements": ["aioautomower==2.7.1"]
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ async def async_setup_entry(
|
||||
update_method=async_update_data,
|
||||
# Polling interval. Will only be polled if there are subscribers.
|
||||
update_interval=timedelta(hours=1),
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user