forked from home-assistant/core
Compare commits
359 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef89d1cd3d | |||
| 9c4fd88a3d | |||
| f5783cd3b5 | |||
| 1200ded24c | |||
| da992e9f45 | |||
| 40326385ae | |||
| da04c32893 | |||
| ae2ff926c1 | |||
| a5d48da07a | |||
| 669daabfdb | |||
| b64ef24f20 | |||
| 86beb9d135 | |||
| 64297aeb8f | |||
| 5650df5cfb | |||
| 83c59d4154 | |||
| 4680ac0cbf | |||
| 8b79d38497 | |||
| 35b1051c67 | |||
| fcc7020946 | |||
| d69d9863b5 | |||
| 885152df81 | |||
| 7ff1bdb098 | |||
| 399299c13c | |||
| c241c2f79c | |||
| b010c6b793 | |||
| 2f380d4b75 | |||
| 19f268a1e1 | |||
| bcd371ac2b | |||
| a5a8d38d08 | |||
| 56298b2c88 | |||
| cf35e9b154 | |||
| 29a65d5620 | |||
| c352cf0bd8 | |||
| e89b47138d | |||
| 339e9e7b48 | |||
| 92780dd217 | |||
| 6133ce0258 | |||
| 57c76b2ea3 | |||
| 149aef9a12 | |||
| 3dddf6b9f6 | |||
| 2a26dea587 | |||
| 31ac03fe50 | |||
| fb1dfb016e | |||
| 8a152a68d8 | |||
| df3e49b24f | |||
| db604170ba | |||
| d8a6d3e1bc | |||
| 6f086a27d4 | |||
| 3993c14f1d | |||
| d63d7841c3 | |||
| e555671765 | |||
| a3319262ac | |||
| eaf711335d | |||
| f120558750 | |||
| 30dc05cdd7 | |||
| 8ce746972f | |||
| f946ed9e16 | |||
| 0ffc1bae76 | |||
| d1a3a5895b | |||
| f9c70fd3c8 | |||
| 70f0ee81c9 | |||
| 95d4254074 | |||
| c8d3e377f0 | |||
| da1c282c1b | |||
| 35c0c9958d | |||
| 93a0bd351a | |||
| dbdd9d74cf | |||
| 3cac87cf30 | |||
| d019045199 | |||
| 8f684ab102 | |||
| c17def27fc | |||
| 27d8d1011e | |||
| e2270a305d | |||
| 6fd8973a00 | |||
| 9a37868244 | |||
| 9327c51115 | |||
| e56e75114a | |||
| f45114371e | |||
| 7e2c12b0a9 | |||
| 050f1085d0 | |||
| 334a02bc2b | |||
| 412fa4c65a | |||
| 2b36befe95 | |||
| aa623cc15c | |||
| b0bb91ec08 | |||
| ce12d82624 | |||
| 9eff9ee374 | |||
| 1ef460cffe | |||
| 42243f1433 | |||
| 8a07c10d88 | |||
| 730a3f7870 | |||
| 718901d2ad | |||
| d95d4d0184 | |||
| 67ce51899f | |||
| 810681b357 | |||
| 0b0f099d27 | |||
| 4a56d0ec1d | |||
| 910654bf78 | |||
| 1a823376d8 | |||
| ba634ac346 | |||
| 92486b1ff0 | |||
| 06d26b7c7f | |||
| 1dcd66d75c | |||
| c811e0db49 | |||
| dc30ddc24b | |||
| 239fa04d02 | |||
| 2be229c5b5 | |||
| 5b4df0f7ff | |||
| 355b51d4c8 | |||
| 0c8074bab4 | |||
| acd98e9b40 | |||
| 0b8d4235c3 | |||
| 4ce859b4e4 | |||
| 18acec32b8 | |||
| cfa2f2ce61 | |||
| aa5ea5ebc3 | |||
| bcea021c14 | |||
| ea2d2ba7b7 | |||
| c5f21fefbe | |||
| 9910f9e0ae | |||
| f0a06efa1f | |||
| 8992d15ffc | |||
| e097dc02dd | |||
| bfae1468d6 | |||
| 09ed6e9f9b | |||
| 040ecb74e0 | |||
| a48e63aa28 | |||
| 19479b2a68 | |||
| 9ae29e243d | |||
| e309bd764b | |||
| 777ffe6946 | |||
| fa0f679a9a | |||
| 26b7e94c4f | |||
| 957998ea8d | |||
| abaeacbd6b | |||
| d76c16fa3a | |||
| 67edb98e59 | |||
| 376a79eb42 | |||
| 41500cbe9b | |||
| 06f27e7e74 | |||
| a3ebfaebe7 | |||
| 8d781ff063 | |||
| bac39f0061 | |||
| c7b702f3c2 | |||
| 3728f3da69 | |||
| 31d8f4b35d | |||
| f113d9aa71 | |||
| 891ad0b1be | |||
| 5c16a8247a | |||
| 483671bf9f | |||
| 6f73d2aac5 | |||
| f5b3661836 | |||
| f70c13214c | |||
| 70e8978123 | |||
| 031b1c26ce | |||
| 13580a334f | |||
| e81bfb959e | |||
| fefe930506 | |||
| 5ac7e8b1ac | |||
| 36512f7157 | |||
| cc3ae9e103 | |||
| 12482216f6 | |||
| 20409d0124 | |||
| a741bc9951 | |||
| 59d2bce369 | |||
| eef318f63c | |||
| 9c8a4bb4eb | |||
| 9c9f1ea685 | |||
| 85d999b020 | |||
| bcddf52364 | |||
| 07e4e1379a | |||
| f9f010643a | |||
| 974c34e2b6 | |||
| 1c3de76b04 | |||
| bee63ca654 | |||
| 29c99f419f | |||
| 3d321c5ca7 | |||
| 4617c16a96 | |||
| a60656bf29 | |||
| 2eb2a65197 | |||
| 867aaf10ee | |||
| 7fe1ac901f | |||
| 5dca3844ef | |||
| b5c75a2f2f | |||
| 62fc9dfd6c | |||
| 0573981d6f | |||
| cc7a4d01e3 | |||
| 293025ab6c | |||
| a490b5e286 | |||
| 7e4da1d03b | |||
| 9e140864eb | |||
| a6f88fb123 | |||
| 386c5ecc3e | |||
| 0d7fb5b026 | |||
| 767b7ba4d6 | |||
| f2cef7245a | |||
| 701a5d7758 | |||
| 244fccdae6 | |||
| 10e6a26717 | |||
| 5fe5013198 | |||
| 0a0584b053 | |||
| 62733e830f | |||
| bbcfb5f30e | |||
| 5b0e0b07b3 | |||
| 05fd64fe80 | |||
| 8b1cfbc46c | |||
| bcade5fe73 | |||
| 4cac20f835 | |||
| b83ada8c19 | |||
| e734a4bc53 | |||
| cd8e3a81db | |||
| 8d034a85fe | |||
| 89e2f06304 | |||
| 4447336083 | |||
| 6e72499f96 | |||
| e4a1efb680 | |||
| b50f5e50c3 | |||
| 2c46a975fb | |||
| edc9aba722 | |||
| 47c9d58b5e | |||
| 476e867fe8 | |||
| 35d18a9a3e | |||
| 8ca5df6fcc | |||
| 0658c7b307 | |||
| 6fae50cb75 | |||
| d2f8c527a5 | |||
| 4812d62ccf | |||
| 45e4f71d1a | |||
| 267721af43 | |||
| 4328f887be | |||
| dfc454d527 | |||
| 7f7064ce59 | |||
| ffed1e8274 | |||
| 37cde54b2b | |||
| a6c5927976 | |||
| b38692f3a7 | |||
| c89acf2abe | |||
| 93a8b60c2b | |||
| 4bf475185e | |||
| b37e9bc79a | |||
| 7038bd67f7 | |||
| 0cb0e3ceeb | |||
| fb13d9ce7c | |||
| 704881743b | |||
| ad692f3341 | |||
| 789a00043a | |||
| aa36229519 | |||
| 626123acc0 | |||
| b870933dc7 | |||
| 9047dcf242 | |||
| dd111416e7 | |||
| ece7ec6a38 | |||
| 2e643c0c75 | |||
| 6294339944 | |||
| 8d5cb20285 | |||
| 7c93d4fccf | |||
| ec3ee7f02c | |||
| 40817dabbf | |||
| eb52943d27 | |||
| a1a5713e10 | |||
| f91583a0fc | |||
| a691bd26cf | |||
| b37253b206 | |||
| f56343f447 | |||
| 3a11a6f973 | |||
| 530611c44e | |||
| 21d0fa640f | |||
| 8da421c442 | |||
| 4bb6787909 | |||
| a4487637ef | |||
| 2e9a3e8c8e | |||
| e1394d720f | |||
| 02a83740cc | |||
| 69ce85d5af | |||
| e708faa4d6 | |||
| e761d5715b | |||
| b5a6e6b9d5 | |||
| ff60a8072e | |||
| 1b61cd9179 | |||
| 2049d892ba | |||
| 0e8bd9805a | |||
| 3ed67f134f | |||
| 9cf9b36637 | |||
| 14485af22d | |||
| bead989e7f | |||
| 9d3cdc85ca | |||
| 4d83cffb39 | |||
| 56ee1753ec | |||
| 0ce7f44294 | |||
| fd8fdba7e8 | |||
| 5ee14f7f7d | |||
| a5461a9a90 | |||
| f5a6c88051 | |||
| 0b8f48205a | |||
| ee1007abdb | |||
| 2807c9eaca | |||
| d8baa38751 | |||
| 8737d84d30 | |||
| 82cc62416e | |||
| 952f40a181 | |||
| eac1d47ec6 | |||
| f9fa1edabf | |||
| 691de148cf | |||
| 6d1d3f4207 | |||
| 6edbee75f0 | |||
| cedade15ef | |||
| 51f6dac97f | |||
| 4536720540 | |||
| ec3596e85d | |||
| 13be486d61 | |||
| 223abb6dca | |||
| acc5edb088 | |||
| d25b4aae14 | |||
| 4febb2e1d3 | |||
| f733f20834 | |||
| 508cffd1b5 | |||
| 8c3ae1b30c | |||
| 9600c7fac1 | |||
| 80b3fec675 | |||
| 4604c5a152 | |||
| 97cc05d0b4 | |||
| 46322a0f59 | |||
| fea15148a1 | |||
| 8cfb8cb084 | |||
| e20d4abfe1 | |||
| 421832e09c | |||
| b42c47e800 | |||
| 57a10a2e0d | |||
| b0d4e5cb65 | |||
| b953f2998c | |||
| 6372bc3aaa | |||
| d5e7cccff9 | |||
| 2935d7d919 | |||
| 5245c94342 | |||
| 5d430f53cd | |||
| fa1df7e334 | |||
| 4c8a919ca3 | |||
| a78e3f7b0f | |||
| c481fdb7d0 | |||
| 7a009ed6cd | |||
| c555fe4462 | |||
| a52761171f | |||
| 54bcd70878 | |||
| c7d2499a52 | |||
| 40ccae3d07 | |||
| 5b39a08feb | |||
| 04c0bca487 | |||
| 9c0427a7ac | |||
| 42c062de68 | |||
| d5af6c595d | |||
| 8c9c915c45 | |||
| 8a7de27946 | |||
| e27baedf32 | |||
| 3e23a4b4ee | |||
| 4c99d2607f | |||
| 109819e9cd | |||
| 0c5b963847 | |||
| e4af09d261 | |||
| a6ade59133 |
+2
-1
@@ -45,6 +45,7 @@ base_platforms: &base_platforms
|
||||
- homeassistant/components/switch/**
|
||||
- homeassistant/components/text/**
|
||||
- homeassistant/components/time/**
|
||||
- homeassistant/components/todo/**
|
||||
- homeassistant/components/tts/**
|
||||
- homeassistant/components/update/**
|
||||
- homeassistant/components/vacuum/**
|
||||
@@ -96,8 +97,8 @@ components: &components
|
||||
- homeassistant/components/persistent_notification/**
|
||||
- homeassistant/components/person/**
|
||||
- homeassistant/components/recorder/**
|
||||
- homeassistant/components/recovery_mode/**
|
||||
- homeassistant/components/repairs/**
|
||||
- homeassistant/components/safe_mode/**
|
||||
- homeassistant/components/script/**
|
||||
- homeassistant/components/shopping_list/**
|
||||
- homeassistant/components/ssdp/**
|
||||
|
||||
+2
-3
@@ -286,9 +286,6 @@ omit =
|
||||
homeassistant/components/edl21/__init__.py
|
||||
homeassistant/components/edl21/sensor.py
|
||||
homeassistant/components/egardia/*
|
||||
homeassistant/components/eight_sleep/__init__.py
|
||||
homeassistant/components/eight_sleep/binary_sensor.py
|
||||
homeassistant/components/eight_sleep/sensor.py
|
||||
homeassistant/components/electric_kiwi/__init__.py
|
||||
homeassistant/components/electric_kiwi/api.py
|
||||
homeassistant/components/electric_kiwi/oauth2.py
|
||||
@@ -992,6 +989,7 @@ omit =
|
||||
homeassistant/components/pushsafer/notify.py
|
||||
homeassistant/components/pyload/sensor.py
|
||||
homeassistant/components/qbittorrent/__init__.py
|
||||
homeassistant/components/qbittorrent/coordinator.py
|
||||
homeassistant/components/qbittorrent/sensor.py
|
||||
homeassistant/components/qnap/__init__.py
|
||||
homeassistant/components/qnap/coordinator.py
|
||||
@@ -1267,6 +1265,7 @@ omit =
|
||||
homeassistant/components/switchbot/sensor.py
|
||||
homeassistant/components/switchbot/switch.py
|
||||
homeassistant/components/switchbot/lock.py
|
||||
homeassistant/components/switchbot_cloud/climate.py
|
||||
homeassistant/components/switchbot_cloud/coordinator.py
|
||||
homeassistant/components/switchbot_cloud/entity.py
|
||||
homeassistant/components/switchbot_cloud/switch.py
|
||||
|
||||
@@ -29,11 +29,11 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2.22.3
|
||||
uses: github/codeql-action/init@v2.22.4
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2.22.3
|
||||
uses: github/codeql-action/analyze@v2.22.4
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -204,6 +204,7 @@ homeassistant.components.light.*
|
||||
homeassistant.components.litejet.*
|
||||
homeassistant.components.litterrobot.*
|
||||
homeassistant.components.local_ip.*
|
||||
homeassistant.components.local_todo.*
|
||||
homeassistant.components.lock.*
|
||||
homeassistant.components.logbook.*
|
||||
homeassistant.components.logger.*
|
||||
|
||||
+12
-6
@@ -319,8 +319,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/efergy/ @tkdrob
|
||||
/tests/components/efergy/ @tkdrob
|
||||
/homeassistant/components/egardia/ @jeroenterheerdt
|
||||
/homeassistant/components/eight_sleep/ @mezz64 @raman325
|
||||
/tests/components/eight_sleep/ @mezz64 @raman325
|
||||
/homeassistant/components/electrasmart/ @jafar-atili
|
||||
/tests/components/electrasmart/ @jafar-atili
|
||||
/homeassistant/components/electric_kiwi/ @mikey0000
|
||||
@@ -423,8 +421,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/fritzbox/ @mib1185 @flabbamann
|
||||
/homeassistant/components/fritzbox_callmonitor/ @cdce8p
|
||||
/tests/components/fritzbox_callmonitor/ @cdce8p
|
||||
/homeassistant/components/fronius/ @nielstron @farmio
|
||||
/tests/components/fronius/ @nielstron @farmio
|
||||
/homeassistant/components/fronius/ @farmio
|
||||
/tests/components/fronius/ @farmio
|
||||
/homeassistant/components/frontend/ @home-assistant/frontend
|
||||
/tests/components/frontend/ @home-assistant/frontend
|
||||
/homeassistant/components/frontier_silicon/ @wlcrs
|
||||
@@ -479,6 +477,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/google_mail/ @tkdrob
|
||||
/homeassistant/components/google_sheets/ @tkdrob
|
||||
/tests/components/google_sheets/ @tkdrob
|
||||
/homeassistant/components/google_tasks/ @allenporter
|
||||
/tests/components/google_tasks/ @allenporter
|
||||
/homeassistant/components/google_travel_time/ @eifinger
|
||||
/tests/components/google_travel_time/ @eifinger
|
||||
/homeassistant/components/govee_ble/ @bdraco @PierreAronnax
|
||||
@@ -586,6 +586,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/image_upload/ @home-assistant/core
|
||||
/homeassistant/components/imap/ @jbouwh
|
||||
/tests/components/imap/ @jbouwh
|
||||
/homeassistant/components/improv_ble/ @emontnemery
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @zxdavb
|
||||
/homeassistant/components/influxdb/ @mdegat01
|
||||
/tests/components/influxdb/ @mdegat01
|
||||
@@ -708,6 +710,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/local_calendar/ @allenporter
|
||||
/homeassistant/components/local_ip/ @issacg
|
||||
/tests/components/local_ip/ @issacg
|
||||
/homeassistant/components/local_todo/ @allenporter
|
||||
/tests/components/local_todo/ @allenporter
|
||||
/homeassistant/components/lock/ @home-assistant/core
|
||||
/tests/components/lock/ @home-assistant/core
|
||||
/homeassistant/components/logbook/ @home-assistant/core
|
||||
@@ -1035,6 +1039,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/recollect_waste/ @bachya
|
||||
/homeassistant/components/recorder/ @home-assistant/core
|
||||
/tests/components/recorder/ @home-assistant/core
|
||||
/homeassistant/components/recovery_mode/ @home-assistant/core
|
||||
/tests/components/recovery_mode/ @home-assistant/core
|
||||
/homeassistant/components/rejseplanen/ @DarkFox
|
||||
/homeassistant/components/remote/ @home-assistant/core
|
||||
/tests/components/remote/ @home-assistant/core
|
||||
@@ -1085,8 +1091,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/rympro/ @OnFreund @elad-bar @maorcc
|
||||
/homeassistant/components/sabnzbd/ @shaiu
|
||||
/tests/components/sabnzbd/ @shaiu
|
||||
/homeassistant/components/safe_mode/ @home-assistant/core
|
||||
/tests/components/safe_mode/ @home-assistant/core
|
||||
/homeassistant/components/saj/ @fredericvl
|
||||
/homeassistant/components/samsungtv/ @chemelli74 @epenet
|
||||
/tests/components/samsungtv/ @chemelli74 @epenet
|
||||
@@ -1303,6 +1307,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/time_date/ @fabaff
|
||||
/tests/components/time_date/ @fabaff
|
||||
/homeassistant/components/tmb/ @alemuro
|
||||
/homeassistant/components/todo/ @home-assistant/core
|
||||
/tests/components/todo/ @home-assistant/core
|
||||
/homeassistant/components/todoist/ @boralyl
|
||||
/tests/components/todoist/ @boralyl
|
||||
/homeassistant/components/tolo/ @MatthiasLohr
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.10.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.10.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.10.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.10.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.10.0
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.10.1
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.10.1
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.10.1
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.10.1
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.10.1
|
||||
codenotary:
|
||||
signer: notary@home-assistant.io
|
||||
base_image: notary@home-assistant.io
|
||||
|
||||
@@ -93,7 +93,9 @@ def get_arguments() -> argparse.Namespace:
|
||||
help="Directory that contains the Home Assistant configuration",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--safe-mode", action="store_true", help="Start Home Assistant in safe mode"
|
||||
"--recovery-mode",
|
||||
action="store_true",
|
||||
help="Start Home Assistant in recovery mode",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug", action="store_true", help="Start Home Assistant in debug mode"
|
||||
@@ -183,7 +185,9 @@ def main() -> int:
|
||||
ensure_config_path(config_dir)
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from . import runner
|
||||
from . import config, runner
|
||||
|
||||
safe_mode = config.safe_mode_enabled(config_dir)
|
||||
|
||||
runtime_conf = runner.RuntimeConfig(
|
||||
config_dir=config_dir,
|
||||
@@ -193,9 +197,10 @@ def main() -> int:
|
||||
log_no_color=args.log_no_color,
|
||||
skip_pip=args.skip_pip,
|
||||
skip_pip_packages=args.skip_pip_packages,
|
||||
safe_mode=args.safe_mode,
|
||||
recovery_mode=args.recovery_mode,
|
||||
debug=args.debug,
|
||||
open_ui=args.open_ui,
|
||||
safe_mode=safe_mode,
|
||||
)
|
||||
|
||||
fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME)
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.const import (
|
||||
from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED
|
||||
|
||||
# These are events that do not contain any sensitive data
|
||||
# Except for state_changed, which is handled accordingly.
|
||||
@@ -28,6 +29,7 @@ SUBSCRIBE_ALLOWLIST: Final[set[str]] = {
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
EVENT_DEVICE_REGISTRY_UPDATED,
|
||||
EVENT_ENTITY_REGISTRY_UPDATED,
|
||||
EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED,
|
||||
EVENT_LOVELACE_UPDATED,
|
||||
EVENT_PANELS_UPDATED,
|
||||
EVENT_RECORDER_5MIN_STATISTICS_GENERATED,
|
||||
|
||||
+15
-12
@@ -120,6 +120,7 @@ async def async_setup_hass(
|
||||
runtime_config.log_no_color,
|
||||
)
|
||||
|
||||
hass.config.safe_mode = runtime_config.safe_mode
|
||||
hass.config.skip_pip = runtime_config.skip_pip
|
||||
hass.config.skip_pip_packages = runtime_config.skip_pip_packages
|
||||
if runtime_config.skip_pip or runtime_config.skip_pip_packages:
|
||||
@@ -137,14 +138,14 @@ async def async_setup_hass(
|
||||
config_dict = None
|
||||
basic_setup_success = False
|
||||
|
||||
if not (safe_mode := runtime_config.safe_mode):
|
||||
if not (recovery_mode := runtime_config.recovery_mode):
|
||||
await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass)
|
||||
|
||||
try:
|
||||
config_dict = await conf_util.async_hass_config_yaml(hass)
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error(
|
||||
"Failed to parse configuration.yaml: %s. Activating safe mode",
|
||||
"Failed to parse configuration.yaml: %s. Activating recovery mode",
|
||||
err,
|
||||
)
|
||||
else:
|
||||
@@ -156,24 +157,24 @@ async def async_setup_hass(
|
||||
)
|
||||
|
||||
if config_dict is None:
|
||||
safe_mode = True
|
||||
recovery_mode = True
|
||||
|
||||
elif not basic_setup_success:
|
||||
_LOGGER.warning("Unable to set up core integrations. Activating safe mode")
|
||||
safe_mode = True
|
||||
_LOGGER.warning("Unable to set up core integrations. Activating recovery mode")
|
||||
recovery_mode = True
|
||||
|
||||
elif (
|
||||
"frontend" in hass.data.get(DATA_SETUP, {})
|
||||
and "frontend" not in hass.config.components
|
||||
):
|
||||
_LOGGER.warning("Detected that frontend did not load. Activating safe mode")
|
||||
_LOGGER.warning("Detected that frontend did not load. Activating recovery mode")
|
||||
# Ask integrations to shut down. It's messy but we can't
|
||||
# do a clean stop without knowing what is broken
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
async with hass.timeout.async_timeout(10):
|
||||
await hass.async_stop()
|
||||
|
||||
safe_mode = True
|
||||
recovery_mode = True
|
||||
old_config = hass.config
|
||||
old_logging = hass.data.get(DATA_LOGGING)
|
||||
|
||||
@@ -187,16 +188,18 @@ async def async_setup_hass(
|
||||
# Setup loader cache after the config dir has been set
|
||||
loader.async_setup(hass)
|
||||
|
||||
if safe_mode:
|
||||
_LOGGER.info("Starting in safe mode")
|
||||
hass.config.safe_mode = True
|
||||
if recovery_mode:
|
||||
_LOGGER.info("Starting in recovery mode")
|
||||
hass.config.recovery_mode = True
|
||||
|
||||
http_conf = (await http.async_get_last_config(hass)) or {}
|
||||
|
||||
await async_from_config_dict(
|
||||
{"safe_mode": {}, "http": http_conf},
|
||||
{"recovery_mode": {}, "http": http_conf},
|
||||
hass,
|
||||
)
|
||||
elif hass.config.safe_mode:
|
||||
_LOGGER.info("Starting in safe mode")
|
||||
|
||||
if runtime_config.open_ui:
|
||||
hass.add_job(open_hass_ui, hass)
|
||||
@@ -471,7 +474,7 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
|
||||
domains = {key.partition(" ")[0] for key in config if key != core.DOMAIN}
|
||||
|
||||
# Add config entry domains
|
||||
if not hass.config.safe_mode:
|
||||
if not hass.config.recovery_mode:
|
||||
domains.update(hass.config_entries.async_domains())
|
||||
|
||||
# Make sure the Hass.io component is loaded
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"google_maps",
|
||||
"google_pubsub",
|
||||
"google_sheets",
|
||||
"google_tasks",
|
||||
"google_translate",
|
||||
"google_travel_time",
|
||||
"google_wifi",
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
},
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["jaraco.abode", "lomond"],
|
||||
"requirements": ["jaraco.abode==3.3.0"]
|
||||
"requirements": ["jaraco.abode==3.3.0", "jaraco.functools==3.9.0"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["accuweather==1.0.0"]
|
||||
"requirements": ["accuweather==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/adax",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["adax", "adax_local"],
|
||||
"requirements": ["adax==0.2.0", "Adax-local==0.1.5"]
|
||||
"requirements": ["adax==0.3.0", "Adax-local==0.1.5"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["adguardhome"],
|
||||
"requirements": ["adguardhome==0.6.1"]
|
||||
"requirements": ["adguardhome==0.6.2"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.3.0"]
|
||||
"requirements": ["aioairzone-cloud==0.3.5"]
|
||||
}
|
||||
|
||||
@@ -630,12 +630,16 @@ class AlexaColorController(AlexaCapability):
|
||||
if name != "color":
|
||||
raise UnsupportedProperty(name)
|
||||
|
||||
hue, saturation = self.entity.attributes.get(light.ATTR_HS_COLOR, (0, 0))
|
||||
hue_saturation: tuple[float, float] | None
|
||||
if (hue_saturation := self.entity.attributes.get(light.ATTR_HS_COLOR)) is None:
|
||||
hue_saturation = (0, 0)
|
||||
if (brightness := self.entity.attributes.get(light.ATTR_BRIGHTNESS)) is None:
|
||||
brightness = 0
|
||||
|
||||
return {
|
||||
"hue": hue,
|
||||
"saturation": saturation / 100.0,
|
||||
"brightness": self.entity.attributes.get(light.ATTR_BRIGHTNESS, 0) / 255.0,
|
||||
"hue": hue_saturation[0],
|
||||
"saturation": hue_saturation[1] / 100.0,
|
||||
"brightness": brightness / 255.0,
|
||||
}
|
||||
|
||||
|
||||
@@ -853,16 +857,18 @@ class AlexaInputController(AlexaCapability):
|
||||
|
||||
def inputs(self) -> list[dict[str, str]] | None:
|
||||
"""Return the list of valid supported inputs."""
|
||||
source_list: list[str] = self.entity.attributes.get(
|
||||
source_list: list[Any] = self.entity.attributes.get(
|
||||
media_player.ATTR_INPUT_SOURCE_LIST, []
|
||||
)
|
||||
return AlexaInputController.get_valid_inputs(source_list)
|
||||
|
||||
@staticmethod
|
||||
def get_valid_inputs(source_list: list[str]) -> list[dict[str, str]]:
|
||||
def get_valid_inputs(source_list: list[Any]) -> list[dict[str, str]]:
|
||||
"""Return list of supported inputs."""
|
||||
input_list: list[dict[str, str]] = []
|
||||
for source in source_list:
|
||||
if not isinstance(source, str):
|
||||
continue
|
||||
formatted_source = (
|
||||
source.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"loggers": ["adb_shell", "androidtv", "pure_python_adb"],
|
||||
"requirements": [
|
||||
"adb-shell[async]==0.4.4",
|
||||
"androidtv[async]==0.0.72",
|
||||
"androidtv[async]==0.0.73",
|
||||
"pure-python-adb[async]==0.3.0.dev0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyatv", "srptools"],
|
||||
"requirements": ["pyatv==0.13.4"],
|
||||
"requirements": ["pyatv==0.14.3"],
|
||||
"zeroconf": [
|
||||
"_mediaremotetv._tcp.local.",
|
||||
"_companion-link._tcp.local.",
|
||||
|
||||
@@ -371,11 +371,15 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def repeat(self) -> RepeatMode | None:
|
||||
"""Return current repeat mode."""
|
||||
if self._playing and self._is_feature_available(FeatureName.Repeat):
|
||||
if (
|
||||
self._playing
|
||||
and self._is_feature_available(FeatureName.Repeat)
|
||||
and (repeat := self._playing.repeat)
|
||||
):
|
||||
return {
|
||||
RepeatState.Track: RepeatMode.ONE,
|
||||
RepeatState.All: RepeatMode.ALL,
|
||||
}.get(self._playing.repeat, RepeatMode.OFF)
|
||||
}.get(repeat, RepeatMode.OFF)
|
||||
return None
|
||||
|
||||
@property
|
||||
|
||||
@@ -21,6 +21,15 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
COMMAND_TO_ATTRIBUTE = {
|
||||
"wakeup": ("power", "turn_on"),
|
||||
"suspend": ("power", "turn_off"),
|
||||
"turn_on": ("power", "turn_on"),
|
||||
"turn_off": ("power", "turn_off"),
|
||||
"volume_up": ("audio", "volume_up"),
|
||||
"volume_down": ("audio", "volume_down"),
|
||||
"home_hold": ("remote_control", "home"),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -61,7 +70,13 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity):
|
||||
|
||||
for _ in range(num_repeats):
|
||||
for single_command in command:
|
||||
attr_value = getattr(self.atv.remote_control, single_command, None)
|
||||
attr_value = None
|
||||
if attributes := COMMAND_TO_ATTRIBUTE.get(single_command):
|
||||
attr_value = self.atv
|
||||
for attr_name in attributes:
|
||||
attr_value = getattr(attr_value, attr_name, None)
|
||||
if not attr_value:
|
||||
attr_value = getattr(self.atv.remote_control, single_command, None)
|
||||
if not attr_value:
|
||||
raise ValueError("Command not found. Exiting sequence")
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.components import stt
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DOMAIN
|
||||
from .const import CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DATA_LAST_WAKE_UP, DOMAIN
|
||||
from .error import PipelineNotFound
|
||||
from .pipeline import (
|
||||
AudioSettings,
|
||||
@@ -58,6 +58,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Assist pipeline integration."""
|
||||
hass.data[DATA_CONFIG] = config.get(DOMAIN, {})
|
||||
|
||||
# wake_word_id -> timestamp of last detection (monotonic_ns)
|
||||
hass.data[DATA_LAST_WAKE_UP] = {}
|
||||
|
||||
await async_setup_pipeline_store(hass)
|
||||
async_register_websocket_api(hass)
|
||||
|
||||
|
||||
@@ -12,11 +12,13 @@ from pathlib import Path
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
import time
|
||||
from typing import Any, Final, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
import wave
|
||||
|
||||
import voluptuous as vol
|
||||
from webrtc_noise_gain import AudioProcessor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from webrtc_noise_gain import AudioProcessor
|
||||
|
||||
from homeassistant.components import (
|
||||
conversation,
|
||||
@@ -522,6 +524,12 @@ class PipelineRun:
|
||||
# Initialize with audio settings
|
||||
self.audio_processor_buffer = AudioBuffer(AUDIO_PROCESSOR_BYTES)
|
||||
if self.audio_settings.needs_processor:
|
||||
# Delay import of webrtc so HA start up is not crashing
|
||||
# on older architectures (armhf).
|
||||
#
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from webrtc_noise_gain import AudioProcessor
|
||||
|
||||
self.audio_processor = AudioProcessor(
|
||||
self.audio_settings.auto_gain_dbfs,
|
||||
self.audio_settings.noise_suppression_level,
|
||||
@@ -681,7 +689,8 @@ class PipelineRun:
|
||||
wake_word_output: dict[str, Any] = {}
|
||||
else:
|
||||
# Avoid duplicate detections by checking cooldown
|
||||
last_wake_up = self.hass.data.get(DATA_LAST_WAKE_UP)
|
||||
wake_up_key = f"{self.wake_word_entity_id}.{result.wake_word_id}"
|
||||
last_wake_up = self.hass.data[DATA_LAST_WAKE_UP].get(wake_up_key)
|
||||
if last_wake_up is not None:
|
||||
sec_since_last_wake_up = time.monotonic() - last_wake_up
|
||||
if sec_since_last_wake_up < wake_word_settings.cooldown_seconds:
|
||||
@@ -689,7 +698,7 @@ class PipelineRun:
|
||||
raise WakeWordDetectionAborted
|
||||
|
||||
# Record last wake up time to block duplicate detections
|
||||
self.hass.data[DATA_LAST_WAKE_UP] = time.monotonic()
|
||||
self.hass.data[DATA_LAST_WAKE_UP][wake_up_key] = time.monotonic()
|
||||
|
||||
if result.queued_audio:
|
||||
# Add audio that was pending at detection.
|
||||
|
||||
@@ -7,8 +7,6 @@ from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Final, cast
|
||||
|
||||
from webrtc_noise_gain import AudioProcessor
|
||||
|
||||
_SAMPLE_RATE: Final = 16000 # Hz
|
||||
_SAMPLE_WIDTH: Final = 2 # bytes
|
||||
|
||||
@@ -51,6 +49,12 @@ class WebRtcVad(VoiceActivityDetector):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize webrtcvad."""
|
||||
# Delay import of webrtc so HA start up is not crashing
|
||||
# on older architectures (armhf).
|
||||
#
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from webrtc_noise_gain import AudioProcessor
|
||||
|
||||
# Just VAD: no noise suppression or auto gain
|
||||
self._audio_processor = AudioProcessor(0, 0)
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from .const import DATA_ASUSWRT, DOMAIN
|
||||
from .router import AsusWrtDevInfo, AsusWrtRouter
|
||||
|
||||
ATTR_LAST_TIME_REACHABLE = "last_time_reachable"
|
||||
|
||||
DEFAULT_DEVICE_NAME = "Unknown device"
|
||||
|
||||
|
||||
@@ -52,6 +54,8 @@ def add_entities(
|
||||
class AsusWrtDevice(ScannerEntity):
|
||||
"""Representation of a AsusWrt device."""
|
||||
|
||||
_unrecorded_attributes = frozenset({ATTR_LAST_TIME_REACHABLE})
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, router: AsusWrtRouter, device: AsusWrtDevInfo) -> None:
|
||||
@@ -97,7 +101,7 @@ class AsusWrtDevice(ScannerEntity):
|
||||
self._attr_extra_state_attributes = {}
|
||||
if self._device.last_activity:
|
||||
self._attr_extra_state_attributes[
|
||||
"last_time_reachable"
|
||||
ATTR_LAST_TIME_REACHABLE
|
||||
] = self._device.last_activity.isoformat(timespec="seconds")
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -25,13 +25,14 @@ from homeassistant.exceptions import (
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers import aiohttp_client, device_registry as dr, discovery_flow
|
||||
from homeassistant.helpers import device_registry as dr, discovery_flow
|
||||
|
||||
from .activity import ActivityStream
|
||||
from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS
|
||||
from .exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
from .gateway import AugustGateway
|
||||
from .subscriber import AugustSubscriberMixin
|
||||
from .util import async_create_august_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,10 +47,7 @@ YALEXS_BLE_DOMAIN = "yalexs_ble"
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up August from a config entry."""
|
||||
# Create an aiohttp session instead of using the default one since the
|
||||
# default one is likely to trigger august's WAF if another integration
|
||||
# is also using Cloudflare
|
||||
session = aiohttp_client.async_create_clientsession(hass)
|
||||
session = async_create_august_clientsession(hass)
|
||||
august_gateway = AugustGateway(hass, session)
|
||||
|
||||
try:
|
||||
|
||||
@@ -13,7 +13,6 @@ from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
|
||||
from .const import (
|
||||
CONF_ACCESS_TOKEN_CACHE_FILE,
|
||||
@@ -26,6 +25,7 @@ from .const import (
|
||||
)
|
||||
from .exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
from .gateway import AugustGateway
|
||||
from .util import async_create_august_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -159,10 +159,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Set up the gateway."""
|
||||
if self._august_gateway is not None:
|
||||
return self._august_gateway
|
||||
# Create an aiohttp session instead of using the default one since the
|
||||
# default one is likely to trigger august's WAF if another integration
|
||||
# is also using Cloudflare
|
||||
self._aiohttp_session = aiohttp_client.async_create_clientsession(self.hass)
|
||||
self._aiohttp_session = async_create_august_clientsession(self.hass)
|
||||
self._august_gateway = AugustGateway(self.hass, self._aiohttp_session)
|
||||
return self._august_gateway
|
||||
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.1"]
|
||||
"requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.2"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""August util functions."""
|
||||
|
||||
import socket
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
|
||||
|
||||
@callback
|
||||
def async_create_august_clientsession(hass: HomeAssistant) -> aiohttp.ClientSession:
|
||||
"""Create an aiohttp session for the august integration."""
|
||||
# Create an aiohttp session instead of using the default one since the
|
||||
# default one is likely to trigger august's WAF if another integration
|
||||
# is also using Cloudflare
|
||||
#
|
||||
# The family is set to AF_INET because IPv6 keeps coming up as an issue
|
||||
# see https://github.com/home-assistant/core/issues/97146
|
||||
#
|
||||
# When https://github.com/aio-libs/aiohttp/issues/4451 is implemented
|
||||
# we can allow IPv6 again
|
||||
#
|
||||
return aiohttp_client.async_create_clientsession(hass, family=socket.AF_INET)
|
||||
@@ -31,6 +31,7 @@ from .const import (
|
||||
SERVICE_SAVE_VIDEO,
|
||||
SERVICE_SEND_PIN,
|
||||
)
|
||||
from .coordinator import BlinkUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -84,6 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
auth_data = deepcopy(dict(entry.data))
|
||||
blink.auth = Auth(auth_data, no_prompt=True, session=session)
|
||||
blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
coordinator = BlinkUpdateCoordinator(hass, blink)
|
||||
|
||||
try:
|
||||
await blink.start()
|
||||
@@ -94,18 +96,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
_LOGGER.debug("Attempting a reauth flow")
|
||||
raise ConfigEntryAuthFailed("Need 2FA for Blink")
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = blink
|
||||
|
||||
if not blink.available:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
await blink.refresh(force=True)
|
||||
|
||||
async def blink_refresh(event_time=None):
|
||||
"""Call blink to refresh info."""
|
||||
await hass.data[DOMAIN][entry.entry_id].refresh(force_cache=True)
|
||||
await coordinator.api.refresh(force_cache=True)
|
||||
|
||||
async def async_save_video(call):
|
||||
"""Call save video service handler."""
|
||||
@@ -118,8 +120,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def send_pin(call):
|
||||
"""Call blink to send new pin."""
|
||||
pin = call.data[CONF_PIN]
|
||||
await hass.data[DOMAIN][entry.entry_id].auth.send_auth_key(
|
||||
hass.data[DOMAIN][entry.entry_id],
|
||||
await coordinator.api.auth.send_auth_key(
|
||||
hass.data[DOMAIN][entry.entry_id].api,
|
||||
pin,
|
||||
)
|
||||
|
||||
@@ -154,26 +156,21 @@ def _async_import_options_from_data_if_missing(
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload Blink entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
if not hass.data[DOMAIN]:
|
||||
return True
|
||||
|
||||
if not unload_ok:
|
||||
return False
|
||||
hass.services.async_remove(DOMAIN, SERVICE_REFRESH)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_SEND_PIN)
|
||||
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
if len(hass.data[DOMAIN]) != 0:
|
||||
return True
|
||||
|
||||
hass.services.async_remove(DOMAIN, SERVICE_REFRESH)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_SEND_PIN)
|
||||
|
||||
return True
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
blink: Blink = hass.data[DOMAIN][entry.entry_id]
|
||||
blink: Blink = hass.data[DOMAIN][entry.entry_id].api
|
||||
blink.refresh_rate = entry.options[CONF_SCAN_INTERVAL]
|
||||
|
||||
|
||||
@@ -186,13 +183,12 @@ async def async_handle_save_video_service(
|
||||
if not hass.config.is_allowed_path(video_path):
|
||||
_LOGGER.error("Can't write %s, no access to path!", video_path)
|
||||
return
|
||||
try:
|
||||
all_cameras = hass.data[DOMAIN][entry.entry_id].cameras
|
||||
if camera_name in all_cameras:
|
||||
all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras
|
||||
if camera_name in all_cameras:
|
||||
try:
|
||||
await all_cameras[camera_name].video_to_file(video_path)
|
||||
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't write image to file: %s", err)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't write image to file: %s", err)
|
||||
|
||||
|
||||
async def async_handle_save_recent_clips_service(
|
||||
@@ -204,10 +200,9 @@ async def async_handle_save_recent_clips_service(
|
||||
if not hass.config.is_allowed_path(clips_dir):
|
||||
_LOGGER.error("Can't write to directory %s, no access to path!", clips_dir)
|
||||
return
|
||||
|
||||
try:
|
||||
all_cameras = hass.data[DOMAIN][entry.entry_id].cameras
|
||||
if camera_name in all_cameras:
|
||||
all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras
|
||||
if camera_name in all_cameras:
|
||||
try:
|
||||
await all_cameras[camera_name].save_recent_clips(output_dir=clips_dir)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't write recent clips to directory: %s", err)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't write recent clips to directory: %s", err)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from blinkpy.blinkpy import Blink
|
||||
from blinkpy.blinkpy import Blink, BlinkSyncModule
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntity,
|
||||
@@ -16,12 +16,14 @@ from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_AWAY,
|
||||
STATE_ALARM_DISARMED,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN
|
||||
from .coordinator import BlinkUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,76 +34,75 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up the Blink Alarm Control Panels."""
|
||||
data = hass.data[DOMAIN][config.entry_id]
|
||||
coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id]
|
||||
|
||||
sync_modules = []
|
||||
for sync_name, sync_module in data.sync.items():
|
||||
sync_modules.append(BlinkSyncModuleHA(data, sync_name, sync_module))
|
||||
async_add_entities(sync_modules, update_before_add=True)
|
||||
for sync_name, sync_module in coordinator.api.sync.items():
|
||||
sync_modules.append(BlinkSyncModuleHA(coordinator, sync_name, sync_module))
|
||||
async_add_entities(sync_modules)
|
||||
|
||||
|
||||
class BlinkSyncModuleHA(AlarmControlPanelEntity):
|
||||
class BlinkSyncModuleHA(
|
||||
CoordinatorEntity[BlinkUpdateCoordinator], AlarmControlPanelEntity
|
||||
):
|
||||
"""Representation of a Blink Alarm Control Panel."""
|
||||
|
||||
_attr_icon = ICON
|
||||
_attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
_attr_name = None
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, data, name: str, sync) -> None:
|
||||
def __init__(
|
||||
self, coordinator: BlinkUpdateCoordinator, name: str, sync: BlinkSyncModule
|
||||
) -> None:
|
||||
"""Initialize the alarm control panel."""
|
||||
self.data: Blink = data
|
||||
super().__init__(coordinator)
|
||||
self.api: Blink = coordinator.api
|
||||
self._coordinator = coordinator
|
||||
self.sync = sync
|
||||
self._name: str = name
|
||||
self._attr_unique_id: str = sync.serial
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, sync.serial)},
|
||||
name=f"{DOMAIN} {name}",
|
||||
manufacturer=DEFAULT_BRAND,
|
||||
serial_number=sync.serial,
|
||||
)
|
||||
self._update_attr()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the device."""
|
||||
if self.data.check_if_ok_to_update():
|
||||
_LOGGER.debug(
|
||||
"Initiating a blink.refresh() from BlinkSyncModule('%s') (%s)",
|
||||
self._name,
|
||||
self.data,
|
||||
)
|
||||
try:
|
||||
await self.data.refresh(force=True)
|
||||
self._attr_available = True
|
||||
except asyncio.TimeoutError:
|
||||
self._attr_available = False
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle coordinator update."""
|
||||
self._update_attr()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
_LOGGER.info("Updating State of Blink Alarm Control Panel '%s'", self._name)
|
||||
|
||||
self.sync.attributes["network_info"] = self.data.networks
|
||||
@callback
|
||||
def _update_attr(self) -> None:
|
||||
"""Update attributes for alarm control panel."""
|
||||
self.sync.attributes["network_info"] = self.api.networks
|
||||
self.sync.attributes["associated_cameras"] = list(self.sync.cameras)
|
||||
self.sync.attributes[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION
|
||||
self._attr_extra_state_attributes = self.sync.attributes
|
||||
|
||||
@property
|
||||
def state(self) -> StateType:
|
||||
"""Return state of alarm."""
|
||||
return STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED
|
||||
self._attr_state = (
|
||||
STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED
|
||||
)
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
try:
|
||||
await self.sync.async_arm(False)
|
||||
await self.sync.refresh(force=True)
|
||||
except asyncio.TimeoutError:
|
||||
self._attr_available = False
|
||||
|
||||
self.async_write_ha_state()
|
||||
except asyncio.TimeoutError as er:
|
||||
raise HomeAssistantError("Blink failed to disarm camera") from er
|
||||
|
||||
await self._coordinator.async_refresh()
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm command."""
|
||||
try:
|
||||
await self.sync.async_arm(True)
|
||||
await self.sync.refresh(force=True)
|
||||
except asyncio.TimeoutError:
|
||||
self._attr_available = False
|
||||
|
||||
except asyncio.TimeoutError as er:
|
||||
raise HomeAssistantError("Blink failed to arm camera away") from er
|
||||
|
||||
await self._coordinator.async_refresh()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -10,9 +10,10 @@ from homeassistant.components.binary_sensor import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
DEFAULT_BRAND,
|
||||
@@ -21,6 +22,7 @@ from .const import (
|
||||
TYPE_CAMERA_ARMED,
|
||||
TYPE_MOTION_DETECTED,
|
||||
)
|
||||
from .coordinator import BlinkUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,39 +47,51 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up the blink binary sensors."""
|
||||
data = hass.data[DOMAIN][config.entry_id]
|
||||
coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id]
|
||||
|
||||
entities = [
|
||||
BlinkBinarySensor(data, camera, description)
|
||||
for camera in data.cameras
|
||||
BlinkBinarySensor(coordinator, camera, description)
|
||||
for camera in coordinator.api.cameras
|
||||
for description in BINARY_SENSORS_TYPES
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BlinkBinarySensor(BinarySensorEntity):
|
||||
class BlinkBinarySensor(CoordinatorEntity[BlinkUpdateCoordinator], BinarySensorEntity):
|
||||
"""Representation of a Blink binary sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, data, camera, description: BinarySensorEntityDescription
|
||||
self,
|
||||
coordinator: BlinkUpdateCoordinator,
|
||||
camera,
|
||||
description: BinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.data = data
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._camera = data.cameras[camera]
|
||||
self._attr_unique_id = f"{self._camera.serial}-{description.key}"
|
||||
self._camera = coordinator.api.cameras[camera]
|
||||
serial = self._camera.serial
|
||||
self._attr_unique_id = f"{serial}-{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._camera.serial)},
|
||||
identifiers={(DOMAIN, serial)},
|
||||
serial_number=serial,
|
||||
name=camera,
|
||||
manufacturer=DEFAULT_BRAND,
|
||||
model=self._camera.camera_type,
|
||||
)
|
||||
self._update_attrs()
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Update sensor state."""
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle update from data coordinator."""
|
||||
self._update_attrs()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@callback
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update attributes for binary sensor."""
|
||||
is_on = self._camera.attributes[self.entity_description.key]
|
||||
_LOGGER.debug(
|
||||
"'%s' %s = %s",
|
||||
@@ -87,4 +101,4 @@ class BlinkBinarySensor(BinarySensorEntity):
|
||||
)
|
||||
if self.entity_description.key == TYPE_BATTERY:
|
||||
is_on = is_on != "ok"
|
||||
return is_on
|
||||
self._attr_is_on = is_on
|
||||
|
||||
@@ -12,25 +12,30 @@ from requests.exceptions import ChunkedEncodingError
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_platform
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DEFAULT_BRAND, DOMAIN, SERVICE_TRIGGER
|
||||
from .coordinator import BlinkUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_VIDEO_CLIP = "video"
|
||||
ATTR_IMAGE = "image"
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up a Blink Camera."""
|
||||
data = hass.data[DOMAIN][config.entry_id]
|
||||
coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id]
|
||||
entities = [
|
||||
BlinkCamera(data, name, camera) for name, camera in data.cameras.items()
|
||||
BlinkCamera(coordinator, name, camera)
|
||||
for name, camera in coordinator.api.cameras.items()
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
@@ -39,20 +44,22 @@ async def async_setup_entry(
|
||||
platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera")
|
||||
|
||||
|
||||
class BlinkCamera(Camera):
|
||||
class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
|
||||
"""An implementation of a Blink Camera."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, data, name, camera) -> None:
|
||||
def __init__(self, coordinator: BlinkUpdateCoordinator, name, camera) -> None:
|
||||
"""Initialize a camera."""
|
||||
super().__init__()
|
||||
self.data = data
|
||||
super().__init__(coordinator)
|
||||
Camera.__init__(self)
|
||||
self._coordinator = coordinator
|
||||
self._camera = camera
|
||||
self._attr_unique_id = f"{camera.serial}-camera"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, camera.serial)},
|
||||
serial_number=camera.serial,
|
||||
name=name,
|
||||
manufacturer=DEFAULT_BRAND,
|
||||
model=camera.camera_type,
|
||||
@@ -68,17 +75,22 @@ class BlinkCamera(Camera):
|
||||
"""Enable motion detection for the camera."""
|
||||
try:
|
||||
await self._camera.async_arm(True)
|
||||
await self.data.refresh(force=True)
|
||||
except asyncio.TimeoutError:
|
||||
self._attr_available = False
|
||||
|
||||
except asyncio.TimeoutError as er:
|
||||
raise HomeAssistantError("Blink failed to arm camera") from er
|
||||
|
||||
self._camera.motion_enabled = True
|
||||
await self._coordinator.async_refresh()
|
||||
|
||||
async def async_disable_motion_detection(self) -> None:
|
||||
"""Disable motion detection for the camera."""
|
||||
try:
|
||||
await self._camera.async_arm(False)
|
||||
await self.data.refresh(force=True)
|
||||
except asyncio.TimeoutError:
|
||||
self._attr_available = False
|
||||
except asyncio.TimeoutError as er:
|
||||
raise HomeAssistantError("Blink failed to disarm camera") from er
|
||||
|
||||
self._camera.motion_enabled = False
|
||||
await self._coordinator.async_refresh()
|
||||
|
||||
@property
|
||||
def motion_detection_enabled(self) -> bool:
|
||||
@@ -94,6 +106,7 @@ class BlinkCamera(Camera):
|
||||
"""Trigger camera to take a snapshot."""
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await self._camera.snap_picture()
|
||||
await self._coordinator.api.refresh()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def camera_image(
|
||||
|
||||
@@ -7,7 +7,6 @@ DEVICE_ID = "Home Assistant"
|
||||
CONF_MIGRATE = "migrate"
|
||||
CONF_CAMERA = "camera"
|
||||
CONF_ALARM_CONTROL_PANEL = "alarm_control_panel"
|
||||
|
||||
DEFAULT_BRAND = "Blink"
|
||||
DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com"
|
||||
DEFAULT_SCAN_INTERVAL = 300
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Blink Coordinator."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from blinkpy.blinkpy import Blink
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""BlinkUpdateCoordinator - In charge of downloading the data for a site."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, api: Blink) -> None:
|
||||
"""Initialize the data service."""
|
||||
self.api = api
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=30),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Async update wrapper."""
|
||||
return await self.api.refresh(force=True)
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/blink",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["blinkpy"],
|
||||
"requirements": ["blinkpy==0.22.2"]
|
||||
"requirements": ["blinkpy==0.22.3"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Support for Blink system camera sensors."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -17,12 +15,13 @@ from homeassistant.const import (
|
||||
EntityCategory,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DEFAULT_BRAND, DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH
|
||||
from .coordinator import BlinkUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,44 +48,59 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Initialize a Blink sensor."""
|
||||
data = hass.data[DOMAIN][config.entry_id]
|
||||
coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id]
|
||||
entities = [
|
||||
BlinkSensor(data, camera, description)
|
||||
for camera in data.cameras
|
||||
BlinkSensor(coordinator, camera, description)
|
||||
for camera in coordinator.api.cameras
|
||||
for description in SENSOR_TYPES
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BlinkSensor(SensorEntity):
|
||||
class BlinkSensor(CoordinatorEntity[BlinkUpdateCoordinator], SensorEntity):
|
||||
"""A Blink camera sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, data, camera, description: SensorEntityDescription) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BlinkUpdateCoordinator,
|
||||
camera,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize sensors from Blink camera."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self.data = data
|
||||
self._camera = data.cameras[camera]
|
||||
self._attr_unique_id = f"{self._camera.serial}-{description.key}"
|
||||
|
||||
self._camera = coordinator.api.cameras[camera]
|
||||
serial = self._camera.serial
|
||||
self._attr_unique_id = f"{serial}-{description.key}"
|
||||
self._sensor_key = (
|
||||
"temperature_calibrated"
|
||||
if description.key == "temperature"
|
||||
else description.key
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._camera.serial)},
|
||||
identifiers={(DOMAIN, serial)},
|
||||
serial_number=serial,
|
||||
name=f"{DOMAIN} {camera}",
|
||||
manufacturer=DEFAULT_BRAND,
|
||||
model=self._camera.camera_type,
|
||||
)
|
||||
self._update_attr()
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | date | datetime | Decimal:
|
||||
"""Retrieve sensor data from the camera."""
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle coordinator update."""
|
||||
self._update_attr()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@callback
|
||||
def _update_attr(self) -> None:
|
||||
"""Update attributes for sensor."""
|
||||
try:
|
||||
native_value = self._camera.attributes[self._sensor_key]
|
||||
self._attr_native_value = self._camera.attributes[self._sensor_key]
|
||||
_LOGGER.debug(
|
||||
"'%s' %s = %s",
|
||||
self._camera.attributes["name"],
|
||||
@@ -94,8 +108,7 @@ class BlinkSensor(SensorEntity):
|
||||
self._attr_native_value,
|
||||
)
|
||||
except KeyError:
|
||||
native_value = None
|
||||
self._attr_native_value = None
|
||||
_LOGGER.error(
|
||||
"%s not a valid camera attribute. Did the API change?", self._sensor_key
|
||||
)
|
||||
return native_value
|
||||
|
||||
@@ -330,7 +330,7 @@ class BaseHaRemoteScanner(BaseHaScanner):
|
||||
prev_manufacturer_data = prev_advertisement.manufacturer_data
|
||||
prev_name = prev_device.name
|
||||
|
||||
if local_name and prev_name and len(prev_name) > len(local_name):
|
||||
if prev_name and (not local_name or len(prev_name) > len(local_name)):
|
||||
local_name = prev_name
|
||||
|
||||
if service_uuids and service_uuids != prev_service_uuids:
|
||||
|
||||
@@ -124,6 +124,7 @@ class BluetoothManager:
|
||||
"storage",
|
||||
"slot_manager",
|
||||
"_debug",
|
||||
"shutdown",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -165,6 +166,7 @@ class BluetoothManager:
|
||||
self.storage = storage
|
||||
self.slot_manager = slot_manager
|
||||
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
|
||||
self.shutdown = False
|
||||
|
||||
@property
|
||||
def supports_passive_scan(self) -> bool:
|
||||
@@ -259,6 +261,7 @@ class BluetoothManager:
|
||||
def async_stop(self, event: Event) -> None:
|
||||
"""Stop the Bluetooth integration at shutdown."""
|
||||
_LOGGER.debug("Stopping bluetooth manager")
|
||||
self.shutdown = True
|
||||
if self._cancel_unavailable_tracking:
|
||||
self._cancel_unavailable_tracking()
|
||||
self._cancel_unavailable_tracking = None
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==0.21.1",
|
||||
"bleak-retry-connector==3.2.1",
|
||||
"bleak-retry-connector==3.3.0",
|
||||
"bluetooth-adapters==0.16.1",
|
||||
"bluetooth-auto-recovery==1.2.3",
|
||||
"bluetooth-data-tools==1.13.0",
|
||||
"bluetooth-data-tools==1.14.0",
|
||||
"dbus-fast==2.12.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import (
|
||||
ATTR_CONNECTIONS,
|
||||
ATTR_IDENTIFIERS,
|
||||
ATTR_NAME,
|
||||
CONF_ENTITY_CATEGORY,
|
||||
@@ -16,7 +17,7 @@ from homeassistant.const import (
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_platform import async_get_current_platform
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -644,6 +645,8 @@ class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProce
|
||||
self._attr_unique_id = f"{address}-{key}"
|
||||
if ATTR_NAME not in self._attr_device_info:
|
||||
self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name
|
||||
if device_id is None:
|
||||
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_BLUETOOTH, address)}
|
||||
self._attr_name = processor.entity_names.get(entity_key)
|
||||
|
||||
@property
|
||||
|
||||
@@ -270,6 +270,10 @@ class HaBleakClientWrapper(BleakClient):
|
||||
"""Connect to the specified GATT server."""
|
||||
assert models.MANAGER is not None
|
||||
manager = models.MANAGER
|
||||
if manager.shutdown:
|
||||
raise BleakError("Bluetooth is already shutdown")
|
||||
if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug("%s: Looking for backend to connect", self.__address)
|
||||
wrapped_backend = self._async_get_best_available_backend_and_device(manager)
|
||||
device = wrapped_backend.device
|
||||
scanner = wrapped_backend.scanner
|
||||
@@ -281,12 +285,14 @@ class HaBleakClientWrapper(BleakClient):
|
||||
timeout=self.__timeout,
|
||||
hass=manager.hass,
|
||||
)
|
||||
if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
if debug_logging:
|
||||
# Only lookup the description if we are going to log it
|
||||
description = ble_device_description(device)
|
||||
_, adv = scanner.discovered_devices_and_advertisement_data[device.address]
|
||||
rssi = adv.rssi
|
||||
_LOGGER.debug("%s: Connecting (last rssi: %s)", description, rssi)
|
||||
_LOGGER.debug(
|
||||
"%s: Connecting via %s (last rssi: %s)", description, scanner.name, rssi
|
||||
)
|
||||
connected = None
|
||||
try:
|
||||
connected = await super().connect(**kwargs)
|
||||
@@ -301,7 +307,9 @@ class HaBleakClientWrapper(BleakClient):
|
||||
manager.async_release_connection_slot(device)
|
||||
|
||||
if debug_logging:
|
||||
_LOGGER.debug("%s: Connected (last rssi: %s)", description, rssi)
|
||||
_LOGGER.debug(
|
||||
"%s: Connected via %s (last rssi: %s)", description, scanner.name, rssi
|
||||
)
|
||||
return connected
|
||||
|
||||
@hass_callback
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bimmer_connected"],
|
||||
"requirements": ["bimmer-connected==0.14.2"]
|
||||
"requirements": ["bimmer-connected==0.14.3"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bosch_shc",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["boschshcpy"],
|
||||
"requirements": ["boschshcpy==0.2.57"],
|
||||
"requirements": ["boschshcpy==0.2.75"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
||||
@@ -383,6 +383,7 @@ async def async_setup_entry(
|
||||
device_info = DeviceInfo(
|
||||
configuration_url=f"http://{entry.data[CONF_HOST]}/",
|
||||
identifiers={(DOMAIN, coordinator.data.serial)},
|
||||
serial_number=coordinator.data.serial,
|
||||
manufacturer="Brother",
|
||||
model=coordinator.data.model,
|
||||
name=coordinator.data.model,
|
||||
|
||||
@@ -14,7 +14,11 @@ from homeassistant.components.bluetooth import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceRegistry, async_get
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_BLUETOOTH,
|
||||
DeviceRegistry,
|
||||
async_get,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
BTHOME_BLE_EVENT,
|
||||
@@ -55,6 +59,7 @@ def process_service_info(
|
||||
sensor_device_info = update.devices[device_key.device_id]
|
||||
device = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
connections={(CONNECTION_BLUETOOTH, address)},
|
||||
identifiers={(BLUETOOTH_DOMAIN, address)},
|
||||
manufacturer=sensor_device_info.manufacturer,
|
||||
model=sensor_device_info.model,
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"location": {
|
||||
"name": "Location"
|
||||
},
|
||||
"messages": {
|
||||
"message": {
|
||||
"name": "Message"
|
||||
},
|
||||
"start_time": {
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["casttube", "pychromecast"],
|
||||
"requirements": ["PyChromecast==13.0.7"],
|
||||
"requirements": ["PyChromecast==13.0.8"],
|
||||
"zeroconf": ["_googlecast._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["hass_nabucasa"],
|
||||
"requirements": ["hass-nabucasa==0.73.0"]
|
||||
"requirements": ["hass-nabucasa==0.74.0"]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import homeassistant.helpers.config_validation as cv
|
||||
from . import get_accounts
|
||||
from .const import (
|
||||
API_ACCOUNT_CURRENCY,
|
||||
API_ACCOUNT_CURRENCY_CODE,
|
||||
API_RATES,
|
||||
API_RESOURCE_TYPE,
|
||||
API_TYPE_VAULT,
|
||||
@@ -81,7 +82,7 @@ async def validate_options(
|
||||
accounts = await hass.async_add_executor_job(get_accounts, client)
|
||||
|
||||
accounts_currencies = [
|
||||
account[API_ACCOUNT_CURRENCY]
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
||||
for account in accounts
|
||||
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
|
||||
]
|
||||
|
||||
@@ -12,14 +12,16 @@ DOMAIN = "coinbase"
|
||||
API_ACCOUNT_AMOUNT = "amount"
|
||||
API_ACCOUNT_BALANCE = "balance"
|
||||
API_ACCOUNT_CURRENCY = "currency"
|
||||
API_ACCOUNT_CURRENCY_CODE = "code"
|
||||
API_ACCOUNT_ID = "id"
|
||||
API_ACCOUNT_NATIVE_BALANCE = "native_balance"
|
||||
API_ACCOUNT_NATIVE_BALANCE = "balance"
|
||||
API_ACCOUNT_NAME = "name"
|
||||
API_ACCOUNTS_DATA = "data"
|
||||
API_RATES = "rates"
|
||||
API_RESOURCE_PATH = "resource_path"
|
||||
API_RESOURCE_TYPE = "type"
|
||||
API_TYPE_VAULT = "vault"
|
||||
API_USD = "USD"
|
||||
|
||||
WALLETS = {
|
||||
"1INCH": "1INCH",
|
||||
|
||||
@@ -14,9 +14,9 @@ from .const import (
|
||||
API_ACCOUNT_AMOUNT,
|
||||
API_ACCOUNT_BALANCE,
|
||||
API_ACCOUNT_CURRENCY,
|
||||
API_ACCOUNT_CURRENCY_CODE,
|
||||
API_ACCOUNT_ID,
|
||||
API_ACCOUNT_NAME,
|
||||
API_ACCOUNT_NATIVE_BALANCE,
|
||||
API_RATES,
|
||||
API_RESOURCE_TYPE,
|
||||
API_TYPE_VAULT,
|
||||
@@ -55,7 +55,7 @@ async def async_setup_entry(
|
||||
entities: list[SensorEntity] = []
|
||||
|
||||
provided_currencies: list[str] = [
|
||||
account[API_ACCOUNT_CURRENCY]
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
||||
for account in instance.accounts
|
||||
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
|
||||
]
|
||||
@@ -106,26 +106,28 @@ class AccountSensor(SensorEntity):
|
||||
self._currency = currency
|
||||
for account in coinbase_data.accounts:
|
||||
if (
|
||||
account[API_ACCOUNT_CURRENCY] != currency
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] != currency
|
||||
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
|
||||
):
|
||||
continue
|
||||
self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}"
|
||||
self._attr_unique_id = (
|
||||
f"coinbase-{account[API_ACCOUNT_ID]}-wallet-"
|
||||
f"{account[API_ACCOUNT_CURRENCY]}"
|
||||
f"{account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]}"
|
||||
)
|
||||
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
|
||||
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY]
|
||||
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY][
|
||||
API_ACCOUNT_CURRENCY_CODE
|
||||
]
|
||||
self._attr_icon = CURRENCY_ICONS.get(
|
||||
account[API_ACCOUNT_CURRENCY], DEFAULT_COIN_ICON
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE],
|
||||
DEFAULT_COIN_ICON,
|
||||
)
|
||||
self._native_balance = round(
|
||||
float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT])
|
||||
/ float(coinbase_data.exchange_rates[API_RATES][currency]),
|
||||
2,
|
||||
)
|
||||
self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][
|
||||
API_ACCOUNT_AMOUNT
|
||||
]
|
||||
self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][
|
||||
API_ACCOUNT_CURRENCY
|
||||
]
|
||||
break
|
||||
|
||||
self._attr_state_class = SensorStateClass.TOTAL
|
||||
@@ -141,7 +143,7 @@ class AccountSensor(SensorEntity):
|
||||
def extra_state_attributes(self) -> dict[str, str]:
|
||||
"""Return the state attributes of the sensor."""
|
||||
return {
|
||||
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._native_currency}",
|
||||
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}",
|
||||
}
|
||||
|
||||
def update(self) -> None:
|
||||
@@ -149,17 +151,17 @@ class AccountSensor(SensorEntity):
|
||||
self._coinbase_data.update()
|
||||
for account in self._coinbase_data.accounts:
|
||||
if (
|
||||
account[API_ACCOUNT_CURRENCY] != self._currency
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
||||
!= self._currency
|
||||
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
|
||||
):
|
||||
continue
|
||||
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
|
||||
self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][
|
||||
API_ACCOUNT_AMOUNT
|
||||
]
|
||||
self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][
|
||||
API_ACCOUNT_CURRENCY
|
||||
]
|
||||
self._native_balance = round(
|
||||
float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT])
|
||||
/ float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
|
||||
2,
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Support for Comelit."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from aiocomelit import ComeliteSerialBridgeApi, ComelitSerialBridgeObject
|
||||
from aiocomelit import ComeliteSerialBridgeApi, ComelitSerialBridgeObject, exceptions
|
||||
from aiocomelit.const import BRIDGE
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -70,14 +68,13 @@ class ComelitSerialBridge(DataUpdateCoordinator):
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update device data."""
|
||||
_LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host)
|
||||
logged = False
|
||||
try:
|
||||
logged = await self.api.login()
|
||||
except (asyncio.exceptions.TimeoutError, aiohttp.ClientConnectorError) as err:
|
||||
await self.api.login()
|
||||
except exceptions.CannotConnect as err:
|
||||
_LOGGER.warning("Connection error for %s", self._host)
|
||||
await self.api.close()
|
||||
raise UpdateFailed(f"Error fetching data: {repr(err)}") from err
|
||||
finally:
|
||||
if not logged:
|
||||
raise ConfigEntryAuthFailed
|
||||
except exceptions.CannotAuthenticate:
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
return await self.api.get_all_devices()
|
||||
|
||||
@@ -51,7 +51,8 @@ class ComelitCoverEntity(
|
||||
self._api = coordinator.api
|
||||
self._device = device
|
||||
super().__init__(coordinator)
|
||||
# Use config_entry.entry_id as base for unique_id because no serial number or mac is available
|
||||
# Use config_entry.entry_id as base for unique_id
|
||||
# because no serial number or mac is available
|
||||
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
|
||||
self._attr_device_info = coordinator.platform_device_info(device)
|
||||
# Device doesn't provide a status so we assume UNKNOWN at first startup
|
||||
@@ -108,7 +109,7 @@ class ComelitCoverEntity(
|
||||
if not self.is_closing and not self.is_opening:
|
||||
return
|
||||
|
||||
action = STATE_OFF if self.is_closing else STATE_ON
|
||||
action = STATE_ON if self.is_closing else STATE_OFF
|
||||
await self._api.set_device_status(COVER, self._device.index, action)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -47,7 +47,8 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity):
|
||||
self._api = coordinator.api
|
||||
self._device = device
|
||||
super().__init__(coordinator)
|
||||
# Use config_entry.entry_id as base for unique_id because no serial number or mac is available
|
||||
# Use config_entry.entry_id as base for unique_id
|
||||
# because no serial number or mac is available
|
||||
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
|
||||
self._attr_device_info = coordinator.platform_device_info(device)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/comelit",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"requirements": ["aiocomelit==0.3.0"]
|
||||
"requirements": ["aiocomelit==0.5.2"]
|
||||
}
|
||||
|
||||
@@ -66,7 +66,8 @@ class ComelitSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity):
|
||||
self._api = coordinator.api
|
||||
self._device = device
|
||||
super().__init__(coordinator)
|
||||
# Use config_entry.entry_id as base for unique_id because no serial number or mac is available
|
||||
# Use config_entry.entry_id as base for unique_id
|
||||
# because no serial number or mac is available
|
||||
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
|
||||
self._attr_device_info = coordinator.platform_device_info(device)
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity):
|
||||
self._api = coordinator.api
|
||||
self._device = device
|
||||
super().__init__(coordinator)
|
||||
# Use config_entry.entry_id as base for unique_id because no serial number or mac is available
|
||||
# Use config_entry.entry_id as base for unique_id
|
||||
# because no serial number or mac is available
|
||||
self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}"
|
||||
self._attr_device_info = coordinator.platform_device_info(device)
|
||||
if device.type == OTHER:
|
||||
|
||||
@@ -38,7 +38,27 @@ from .deconz_device import DeconzDevice
|
||||
from .gateway import DeconzGateway, get_gateway_from_config_entry
|
||||
|
||||
DECONZ_GROUP = "is_deconz_group"
|
||||
EFFECT_TO_DECONZ = {EFFECT_COLORLOOP: LightEffect.COLOR_LOOP, "None": LightEffect.NONE}
|
||||
EFFECT_TO_DECONZ = {
|
||||
EFFECT_COLORLOOP: LightEffect.COLOR_LOOP,
|
||||
"None": LightEffect.NONE,
|
||||
# Specific to Lidl christmas light
|
||||
"carnival": LightEffect.CARNIVAL,
|
||||
"collide": LightEffect.COLLIDE,
|
||||
"fading": LightEffect.FADING,
|
||||
"fireworks": LightEffect.FIREWORKS,
|
||||
"flag": LightEffect.FLAG,
|
||||
"glow": LightEffect.GLOW,
|
||||
"rainbow": LightEffect.RAINBOW,
|
||||
"snake": LightEffect.SNAKE,
|
||||
"snow": LightEffect.SNOW,
|
||||
"sparkles": LightEffect.SPARKLES,
|
||||
"steady": LightEffect.STEADY,
|
||||
"strobe": LightEffect.STROBE,
|
||||
"twinkle": LightEffect.TWINKLE,
|
||||
"updown": LightEffect.UPDOWN,
|
||||
"vintage": LightEffect.VINTAGE,
|
||||
"waves": LightEffect.WAVES,
|
||||
}
|
||||
FLASH_TO_DECONZ = {FLASH_SHORT: LightAlert.SHORT, FLASH_LONG: LightAlert.LONG}
|
||||
|
||||
DECONZ_TO_COLOR_MODE = {
|
||||
@@ -47,6 +67,25 @@ DECONZ_TO_COLOR_MODE = {
|
||||
LightColorMode.XY: ColorMode.XY,
|
||||
}
|
||||
|
||||
TS0601_EFFECTS = [
|
||||
"carnival",
|
||||
"collide",
|
||||
"fading",
|
||||
"fireworks",
|
||||
"flag",
|
||||
"glow",
|
||||
"rainbow",
|
||||
"snake",
|
||||
"snow",
|
||||
"sparkles",
|
||||
"steady",
|
||||
"strobe",
|
||||
"twinkle",
|
||||
"updown",
|
||||
"vintage",
|
||||
"waves",
|
||||
]
|
||||
|
||||
_LightDeviceT = TypeVar("_LightDeviceT", bound=Group | Light)
|
||||
|
||||
|
||||
@@ -161,6 +200,8 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity):
|
||||
if device.effect is not None:
|
||||
self._attr_supported_features |= LightEntityFeature.EFFECT
|
||||
self._attr_effect_list = [EFFECT_COLORLOOP]
|
||||
if device.model_id == "TS0601":
|
||||
self._attr_effect_list += TS0601_EFFECTS
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
|
||||
@@ -5,9 +5,9 @@ from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, Platform
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import DeviceAutomationType, async_get_device_automation_platform
|
||||
@@ -55,31 +55,42 @@ async def async_validate_device_automation_config(
|
||||
platform = await async_get_device_automation_platform(
|
||||
hass, validated_config[CONF_DOMAIN], automation_type
|
||||
)
|
||||
|
||||
# Make sure the referenced device and optional entity exist
|
||||
device_registry = dr.async_get(hass)
|
||||
if not (device := device_registry.async_get(validated_config[CONF_DEVICE_ID])):
|
||||
# The device referenced by the device automation does not exist
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"Unknown device '{validated_config[CONF_DEVICE_ID]}'"
|
||||
)
|
||||
if entity_id := validated_config.get(CONF_ENTITY_ID):
|
||||
try:
|
||||
er.async_validate_entity_id(er.async_get(hass), entity_id)
|
||||
except vol.Invalid as err:
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"Unknown entity '{entity_id}'"
|
||||
) from err
|
||||
|
||||
if not hasattr(platform, DYNAMIC_VALIDATOR[automation_type]):
|
||||
# Pass the unvalidated config to avoid mutating the raw config twice
|
||||
return cast(
|
||||
ConfigType, getattr(platform, STATIC_VALIDATOR[automation_type])(config)
|
||||
)
|
||||
|
||||
# Bypass checks for entity platforms
|
||||
# Devices are not linked to config entries from entity platform domains, skip
|
||||
# the checks below which look for a config entry matching the device automation
|
||||
# domain
|
||||
if (
|
||||
automation_type == DeviceAutomationType.ACTION
|
||||
and validated_config[CONF_DOMAIN] in ENTITY_PLATFORMS
|
||||
):
|
||||
# Pass the unvalidated config to avoid mutating the raw config twice
|
||||
return cast(
|
||||
ConfigType,
|
||||
await getattr(platform, DYNAMIC_VALIDATOR[automation_type])(hass, config),
|
||||
)
|
||||
|
||||
# Only call the dynamic validator if the referenced device exists and the relevant
|
||||
# config entry is loaded
|
||||
registry = dr.async_get(hass)
|
||||
if not (device := registry.async_get(validated_config[CONF_DEVICE_ID])):
|
||||
# The device referenced by the device automation does not exist
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"Unknown device '{validated_config[CONF_DEVICE_ID]}'"
|
||||
)
|
||||
|
||||
# Find a config entry with the same domain as the device automation
|
||||
device_config_entry = None
|
||||
for entry_id in device.config_entries:
|
||||
if (
|
||||
@@ -91,7 +102,7 @@ async def async_validate_device_automation_config(
|
||||
break
|
||||
|
||||
if not device_config_entry:
|
||||
# The config entry referenced by the device automation does not exist
|
||||
# There's no config entry with the same domain as the device automation
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"Device '{validated_config[CONF_DEVICE_ID]}' has no config entry from "
|
||||
f"domain '{validated_config[CONF_DOMAIN]}'"
|
||||
|
||||
@@ -25,9 +25,9 @@ see:
|
||||
gps_accuracy:
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 100
|
||||
unit_of_measurement: "%"
|
||||
min: 0
|
||||
mode: box
|
||||
unit_of_measurement: "m"
|
||||
battery:
|
||||
selector:
|
||||
number:
|
||||
|
||||
@@ -52,7 +52,6 @@ class DevoloEntity(Entity):
|
||||
identifiers={(DOMAIN, str(device.serial_number))},
|
||||
manufacturer="devolo",
|
||||
model=device.product,
|
||||
name=entry.title,
|
||||
serial_number=device.serial_number,
|
||||
sw_version=device.firmware_version,
|
||||
)
|
||||
|
||||
@@ -53,6 +53,8 @@ class DSMRConnection:
|
||||
self._protocol = protocol
|
||||
self._telegram: dict[str, DSMRObject] = {}
|
||||
self._equipment_identifier = obis_ref.EQUIPMENT_IDENTIFIER
|
||||
if dsmr_version == "5B":
|
||||
self._equipment_identifier = obis_ref.BELGIUM_EQUIPMENT_IDENTIFIER
|
||||
if dsmr_version == "5L":
|
||||
self._equipment_identifier = obis_ref.LUXEMBOURG_EQUIPMENT_IDENTIFIER
|
||||
if dsmr_version == "Q3D":
|
||||
|
||||
@@ -34,6 +34,3 @@ DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"}
|
||||
|
||||
DSMR_PROTOCOL = "dsmr_protocol"
|
||||
RFXTRX_DSMR_PROTOCOL = "rfxtrx_dsmr_protocol"
|
||||
|
||||
# Temp obis until sensors replaced by mbus variants
|
||||
BELGIUM_5MIN_GAS_METER_READING = r"\d-\d:24\.2\.3.+?\r\n"
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["dsmr_parser"],
|
||||
"requirements": ["dsmr-parser==1.3.0"]
|
||||
"requirements": ["dsmr-parser==1.3.1"]
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from .const import (
|
||||
BELGIUM_5MIN_GAS_METER_READING,
|
||||
CONF_DSMR_VERSION,
|
||||
CONF_PRECISION,
|
||||
CONF_PROTOCOL,
|
||||
@@ -382,16 +381,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="belgium_5min_gas_meter_reading",
|
||||
translation_key="gas_meter_reading",
|
||||
obis_reference=BELGIUM_5MIN_GAS_METER_READING,
|
||||
dsmr_versions={"5B"},
|
||||
is_gas=True,
|
||||
force_update=True,
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="gas_meter_reading",
|
||||
translation_key="gas_meter_reading",
|
||||
@@ -405,6 +394,31 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def add_gas_sensor_5B(telegram: dict[str, DSMRObject]) -> DSMRSensorEntityDescription:
|
||||
"""Return correct entity for 5B Gas meter."""
|
||||
ref = None
|
||||
if obis_references.BELGIUM_MBUS1_METER_READING2 in telegram:
|
||||
ref = obis_references.BELGIUM_MBUS1_METER_READING2
|
||||
elif obis_references.BELGIUM_MBUS2_METER_READING2 in telegram:
|
||||
ref = obis_references.BELGIUM_MBUS2_METER_READING2
|
||||
elif obis_references.BELGIUM_MBUS3_METER_READING2 in telegram:
|
||||
ref = obis_references.BELGIUM_MBUS3_METER_READING2
|
||||
elif obis_references.BELGIUM_MBUS4_METER_READING2 in telegram:
|
||||
ref = obis_references.BELGIUM_MBUS4_METER_READING2
|
||||
elif ref is None:
|
||||
ref = obis_references.BELGIUM_MBUS1_METER_READING2
|
||||
return DSMRSensorEntityDescription(
|
||||
key="belgium_5min_gas_meter_reading",
|
||||
translation_key="gas_meter_reading",
|
||||
obis_reference=ref,
|
||||
dsmr_versions={"5B"},
|
||||
is_gas=True,
|
||||
force_update=True,
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
@@ -438,6 +452,10 @@ async def async_setup_entry(
|
||||
return (entity_description.device_class, UNIT_CONVERSION[uom])
|
||||
return (entity_description.device_class, uom)
|
||||
|
||||
all_sensors = SENSORS
|
||||
if dsmr_version == "5B":
|
||||
all_sensors += (add_gas_sensor_5B(telegram),)
|
||||
|
||||
entities.extend(
|
||||
[
|
||||
DSMREntity(
|
||||
@@ -448,7 +466,7 @@ async def async_setup_entry(
|
||||
telegram, description
|
||||
), # type: ignore[arg-type]
|
||||
)
|
||||
for description in SENSORS
|
||||
for description in all_sensors
|
||||
if (
|
||||
description.dsmr_versions is None
|
||||
or dsmr_version in description.dsmr_versions
|
||||
|
||||
@@ -141,6 +141,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
translation_key="gas_meter_usage",
|
||||
entity_registry_enabled_default=False,
|
||||
icon="mdi:fire",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
@@ -283,6 +284,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
key="dsmr/day-consumption/gas",
|
||||
translation_key="daily_gas_usage",
|
||||
icon="mdi:counter",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
@@ -460,6 +462,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
key="dsmr/current-month/gas",
|
||||
translation_key="current_month_gas_usage",
|
||||
icon="mdi:counter",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
@@ -538,6 +541,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
key="dsmr/current-year/gas",
|
||||
translation_key="current_year_gas_usage",
|
||||
icon="mdi:counter",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
|
||||
@@ -34,6 +34,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/duotecno",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyDuotecno==2023.10.1"]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecoforest",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyecoforest"],
|
||||
"requirements": ["pyecoforest==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/econet",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["paho_mqtt", "pyeconet"],
|
||||
"requirements": ["pyeconet==0.1.20"]
|
||||
"requirements": ["pyeconet==0.1.22"]
|
||||
}
|
||||
|
||||
@@ -1,222 +1,37 @@
|
||||
"""Support for Eight smart mattress covers and mattresses."""
|
||||
"""The Eight Sleep integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyeight.eight import EightSleep
|
||||
from pyeight.exceptions import RequestError
|
||||
from pyeight.user import EightUser
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_HW_VERSION,
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL,
|
||||
ATTR_SW_VERSION,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.device_registry import DeviceInfo, async_get
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, NAME_MAP
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
HEAT_SCAN_INTERVAL = timedelta(seconds=60)
|
||||
USER_SCAN_INTERVAL = timedelta(seconds=300)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
}
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
DOMAIN = "eight_sleep"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EightSleepConfigEntryData:
|
||||
"""Data used for all entities for a given config entry."""
|
||||
|
||||
api: EightSleep
|
||||
heat_coordinator: DataUpdateCoordinator
|
||||
user_coordinator: DataUpdateCoordinator
|
||||
|
||||
|
||||
def _get_device_unique_id(eight: EightSleep, user_obj: EightUser | None = None) -> str:
|
||||
"""Get the device's unique ID."""
|
||||
unique_id = eight.device_id
|
||||
assert unique_id
|
||||
if user_obj:
|
||||
unique_id = f"{unique_id}.{user_obj.user_id}.{user_obj.side}"
|
||||
return unique_id
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Old set up method for the Eight Sleep component."""
|
||||
if DOMAIN in config:
|
||||
_LOGGER.warning(
|
||||
"Your Eight Sleep configuration has been imported into the UI; "
|
||||
"please remove it from configuration.yaml as support for it "
|
||||
"will be removed in a future release"
|
||||
)
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the Eight Sleep config entry."""
|
||||
eight = EightSleep(
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
hass.config.time_zone,
|
||||
client_session=async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
# Authenticate, build sensors
|
||||
try:
|
||||
success = await eight.start()
|
||||
except RequestError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
if not success:
|
||||
# Authentication failed, cannot continue
|
||||
return False
|
||||
|
||||
heat_coordinator: DataUpdateCoordinator = DataUpdateCoordinator(
|
||||
async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
|
||||
"""Set up Eight Sleep from a config entry."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"{DOMAIN}_heat",
|
||||
update_interval=HEAT_SCAN_INTERVAL,
|
||||
update_method=eight.update_device_data,
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="integration_removed",
|
||||
translation_placeholders={
|
||||
"entries": "/config/integrations/integration/eight_sleep"
|
||||
},
|
||||
)
|
||||
user_coordinator: DataUpdateCoordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"{DOMAIN}_user",
|
||||
update_interval=USER_SCAN_INTERVAL,
|
||||
update_method=eight.update_user_data,
|
||||
)
|
||||
await heat_coordinator.async_config_entry_first_refresh()
|
||||
await user_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
if not eight.users:
|
||||
# No users, cannot continue
|
||||
return False
|
||||
|
||||
dev_reg = async_get(hass)
|
||||
assert eight.device_data
|
||||
device_data = {
|
||||
ATTR_MANUFACTURER: "Eight Sleep",
|
||||
ATTR_MODEL: eight.device_data.get("modelString", UNDEFINED),
|
||||
ATTR_HW_VERSION: eight.device_data.get("sensorInfo", {}).get(
|
||||
"hwRevision", UNDEFINED
|
||||
),
|
||||
ATTR_SW_VERSION: eight.device_data.get("firmwareVersion", UNDEFINED),
|
||||
}
|
||||
dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, _get_device_unique_id(eight))},
|
||||
name=f"{entry.data[CONF_USERNAME]}'s Eight Sleep",
|
||||
**device_data,
|
||||
)
|
||||
for user in eight.users.values():
|
||||
assert user.user_profile
|
||||
dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, _get_device_unique_id(eight, user))},
|
||||
name=f"{user.user_profile['firstName']}'s Eight Sleep Side",
|
||||
via_device=(DOMAIN, _get_device_unique_id(eight)),
|
||||
**device_data,
|
||||
)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EightSleepConfigEntryData(
|
||||
eight, heat_coordinator, user_coordinator
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
# stop the API before unloading everything
|
||||
config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id]
|
||||
await config_entry_data.api.stop()
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
if not hass.data[DOMAIN]:
|
||||
hass.data.pop(DOMAIN)
|
||||
if all(
|
||||
config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
for config_entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if config_entry.entry_id != entry.entry_id
|
||||
):
|
||||
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]):
|
||||
"""The base Eight Sleep entity class."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
eight: EightSleep,
|
||||
user_id: str | None,
|
||||
sensor: str,
|
||||
) -> None:
|
||||
"""Initialize the data object."""
|
||||
super().__init__(coordinator)
|
||||
self._config_entry = entry
|
||||
self._eight = eight
|
||||
self._user_id = user_id
|
||||
self._sensor = sensor
|
||||
self._user_obj: EightUser | None = None
|
||||
if user_id:
|
||||
self._user_obj = self._eight.users[user_id]
|
||||
|
||||
mapped_name = NAME_MAP.get(sensor, sensor.replace("_", " ").title())
|
||||
if self._user_obj is not None:
|
||||
assert self._user_obj.user_profile
|
||||
name = f"{self._user_obj.user_profile['firstName']}'s {mapped_name}"
|
||||
self._attr_name = name
|
||||
else:
|
||||
self._attr_name = f"Eight Sleep {mapped_name}"
|
||||
unique_id = f"{_get_device_unique_id(eight, self._user_obj)}.{sensor}"
|
||||
self._attr_unique_id = unique_id
|
||||
identifiers = {(DOMAIN, _get_device_unique_id(eight, self._user_obj))}
|
||||
self._attr_device_info = DeviceInfo(identifiers=identifiers)
|
||||
|
||||
async def async_heat_set(self, target: int, duration: int) -> None:
|
||||
"""Handle eight sleep service calls."""
|
||||
if self._user_obj is None:
|
||||
raise HomeAssistantError(
|
||||
"This entity does not support the heat set service."
|
||||
)
|
||||
|
||||
await self._user_obj.set_heating_level(target, duration)
|
||||
config_entry_data: EightSleepConfigEntryData = self.hass.data[DOMAIN][
|
||||
self._config_entry.entry_id
|
||||
]
|
||||
await config_entry_data.heat_coordinator.async_request_refresh()
|
||||
return True
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Support for Eight Sleep binary sensors."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pyeight.eight import EightSleep
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from . import EightSleepBaseEntity, EightSleepConfigEntryData
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
BINARY_SENSORS = ["bed_presence"]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up the eight sleep binary sensor."""
|
||||
config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id]
|
||||
eight = config_entry_data.api
|
||||
heat_coordinator = config_entry_data.heat_coordinator
|
||||
async_add_entities(
|
||||
EightHeatSensor(entry, heat_coordinator, eight, user.user_id, binary_sensor)
|
||||
for user in eight.users.values()
|
||||
for binary_sensor in BINARY_SENSORS
|
||||
)
|
||||
|
||||
|
||||
class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity):
|
||||
"""Representation of a Eight Sleep heat-based sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.OCCUPANCY
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
eight: EightSleep,
|
||||
user_id: str | None,
|
||||
sensor: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(entry, coordinator, eight, user_id, sensor)
|
||||
assert self._user_obj
|
||||
_LOGGER.debug(
|
||||
"Presence Sensor: %s, Side: %s, User: %s",
|
||||
sensor,
|
||||
self._user_obj.side,
|
||||
user_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the binary sensor is on."""
|
||||
assert self._user_obj
|
||||
return bool(self._user_obj.bed_presence)
|
||||
@@ -1,90 +1,11 @@
|
||||
"""Config flow for Eight Sleep integration."""
|
||||
from __future__ import annotations
|
||||
"""The Eight Sleep integration config flow."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
|
||||
from pyeight.eight import EightSleep
|
||||
from pyeight.exceptions import RequestError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.EMAIL)
|
||||
),
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
)
|
||||
from . import DOMAIN
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
class EightSleepConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Eight Sleep."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def _validate_data(self, config: dict[str, str]) -> str | None:
|
||||
"""Validate input data and return any error."""
|
||||
await self.async_set_unique_id(config[CONF_USERNAME].lower())
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
eight = EightSleep(
|
||||
config[CONF_USERNAME],
|
||||
config[CONF_PASSWORD],
|
||||
self.hass.config.time_zone,
|
||||
client_session=async_get_clientsession(self.hass),
|
||||
)
|
||||
|
||||
try:
|
||||
await eight.fetch_token()
|
||||
except RequestError as err:
|
||||
return str(err)
|
||||
|
||||
return None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
|
||||
)
|
||||
|
||||
if (err := await self._validate_data(user_input)) is not None:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors={"base": "cannot_connect"},
|
||||
description_placeholders={"error": err},
|
||||
)
|
||||
|
||||
return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input)
|
||||
|
||||
async def async_step_import(self, import_config: dict) -> FlowResult:
|
||||
"""Handle import."""
|
||||
if (err := await self._validate_data(import_config)) is not None:
|
||||
_LOGGER.error("Unable to import configuration.yaml configuration: %s", err)
|
||||
return self.async_abort(
|
||||
reason="cannot_connect", description_placeholders={"error": err}
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=import_config[CONF_USERNAME], data=import_config
|
||||
)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Eight Sleep constants."""
|
||||
DOMAIN = "eight_sleep"
|
||||
|
||||
HEAT_ENTITY = "heat"
|
||||
USER_ENTITY = "user"
|
||||
|
||||
NAME_MAP = {
|
||||
"current_sleep": "Sleep Session",
|
||||
"current_sleep_fitness": "Sleep Fitness",
|
||||
"last_sleep": "Previous Sleep Session",
|
||||
}
|
||||
|
||||
SERVICE_HEAT_SET = "heat_set"
|
||||
|
||||
ATTR_TARGET = "target"
|
||||
ATTR_DURATION = "duration"
|
||||
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"domain": "eight_sleep",
|
||||
"name": "Eight Sleep",
|
||||
"codeowners": ["@mezz64", "@raman325"],
|
||||
"config_flow": true,
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/eight_sleep",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyeight"],
|
||||
"requirements": ["pyEight==0.3.2"]
|
||||
"requirements": []
|
||||
}
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
"""Support for Eight Sleep sensors."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyeight.eight import EightSleep
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from . import EightSleepBaseEntity, EightSleepConfigEntryData
|
||||
from .const import ATTR_DURATION, ATTR_TARGET, DOMAIN, SERVICE_HEAT_SET
|
||||
|
||||
ATTR_ROOM_TEMP = "Room Temperature"
|
||||
ATTR_AVG_ROOM_TEMP = "Average Room Temperature"
|
||||
ATTR_BED_TEMP = "Bed Temperature"
|
||||
ATTR_AVG_BED_TEMP = "Average Bed Temperature"
|
||||
ATTR_RESP_RATE = "Respiratory Rate"
|
||||
ATTR_AVG_RESP_RATE = "Average Respiratory Rate"
|
||||
ATTR_HEART_RATE = "Heart Rate"
|
||||
ATTR_AVG_HEART_RATE = "Average Heart Rate"
|
||||
ATTR_SLEEP_DUR = "Time Slept"
|
||||
ATTR_LIGHT_PERC = f"Light Sleep {PERCENTAGE}"
|
||||
ATTR_DEEP_PERC = f"Deep Sleep {PERCENTAGE}"
|
||||
ATTR_REM_PERC = f"REM Sleep {PERCENTAGE}"
|
||||
ATTR_TNT = "Tosses & Turns"
|
||||
ATTR_SLEEP_STAGE = "Sleep Stage"
|
||||
ATTR_TARGET_HEAT = "Target Heating Level"
|
||||
ATTR_ACTIVE_HEAT = "Heating Active"
|
||||
ATTR_DURATION_HEAT = "Heating Time Remaining"
|
||||
ATTR_PROCESSING = "Processing"
|
||||
ATTR_SESSION_START = "Session Start"
|
||||
ATTR_FIT_DATE = "Fitness Date"
|
||||
ATTR_FIT_DURATION_SCORE = "Fitness Duration Score"
|
||||
ATTR_FIT_ASLEEP_SCORE = "Fitness Asleep Score"
|
||||
ATTR_FIT_OUT_SCORE = "Fitness Out-of-Bed Score"
|
||||
ATTR_FIT_WAKEUP_SCORE = "Fitness Wakeup Score"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
EIGHT_USER_SENSORS = [
|
||||
"current_sleep",
|
||||
"current_sleep_fitness",
|
||||
"last_sleep",
|
||||
"bed_temperature",
|
||||
"sleep_stage",
|
||||
]
|
||||
EIGHT_HEAT_SENSORS = ["bed_state"]
|
||||
EIGHT_ROOM_SENSORS = ["room_temperature"]
|
||||
|
||||
VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=-100, max=100))
|
||||
VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800))
|
||||
|
||||
SERVICE_EIGHT_SCHEMA = {
|
||||
ATTR_TARGET: VALID_TARGET_HEAT,
|
||||
ATTR_DURATION: VALID_DURATION,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up the eight sleep sensors."""
|
||||
config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id]
|
||||
eight = config_entry_data.api
|
||||
heat_coordinator = config_entry_data.heat_coordinator
|
||||
user_coordinator = config_entry_data.user_coordinator
|
||||
|
||||
all_sensors: list[SensorEntity] = []
|
||||
|
||||
for obj in eight.users.values():
|
||||
all_sensors.extend(
|
||||
EightUserSensor(entry, user_coordinator, eight, obj.user_id, sensor)
|
||||
for sensor in EIGHT_USER_SENSORS
|
||||
)
|
||||
all_sensors.extend(
|
||||
EightHeatSensor(entry, heat_coordinator, eight, obj.user_id, sensor)
|
||||
for sensor in EIGHT_HEAT_SENSORS
|
||||
)
|
||||
|
||||
all_sensors.extend(
|
||||
EightRoomSensor(entry, user_coordinator, eight, sensor)
|
||||
for sensor in EIGHT_ROOM_SENSORS
|
||||
)
|
||||
|
||||
async_add_entities(all_sensors)
|
||||
|
||||
platform = async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_HEAT_SET,
|
||||
SERVICE_EIGHT_SCHEMA,
|
||||
"async_heat_set",
|
||||
)
|
||||
|
||||
|
||||
class EightHeatSensor(EightSleepBaseEntity, SensorEntity):
|
||||
"""Representation of an eight sleep heat-based sensor."""
|
||||
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
eight: EightSleep,
|
||||
user_id: str,
|
||||
sensor: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(entry, coordinator, eight, user_id, sensor)
|
||||
assert self._user_obj
|
||||
|
||||
_LOGGER.debug(
|
||||
"Heat Sensor: %s, Side: %s, User: %s",
|
||||
self._sensor,
|
||||
self._user_obj.side,
|
||||
self._user_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the state of the sensor."""
|
||||
assert self._user_obj
|
||||
return self._user_obj.heating_level
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return device state attributes."""
|
||||
assert self._user_obj
|
||||
return {
|
||||
ATTR_TARGET_HEAT: self._user_obj.target_heating_level,
|
||||
ATTR_ACTIVE_HEAT: self._user_obj.now_heating,
|
||||
ATTR_DURATION_HEAT: self._user_obj.heating_remaining,
|
||||
}
|
||||
|
||||
|
||||
def _get_breakdown_percent(
|
||||
attr: dict[str, Any], key: str, denominator: int | float
|
||||
) -> int | float:
|
||||
"""Get a breakdown percent."""
|
||||
try:
|
||||
return round((attr["breakdown"][key] / denominator) * 100, 2)
|
||||
except (ZeroDivisionError, KeyError):
|
||||
return 0
|
||||
|
||||
|
||||
def _get_rounded_value(attr: dict[str, Any], key: str) -> int | float | None:
|
||||
"""Get rounded value for given key."""
|
||||
if (val := attr.get(key)) is None:
|
||||
return None
|
||||
return round(val, 2)
|
||||
|
||||
|
||||
class EightUserSensor(EightSleepBaseEntity, SensorEntity):
|
||||
"""Representation of an eight sleep user-based sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
eight: EightSleep,
|
||||
user_id: str,
|
||||
sensor: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(entry, coordinator, eight, user_id, sensor)
|
||||
assert self._user_obj
|
||||
|
||||
if self._sensor == "bed_temperature":
|
||||
self._attr_icon = "mdi:thermometer"
|
||||
self._attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
elif self._sensor in ("current_sleep", "last_sleep", "current_sleep_fitness"):
|
||||
self._attr_native_unit_of_measurement = "Score"
|
||||
|
||||
if self._sensor != "sleep_stage":
|
||||
self._attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
_LOGGER.debug(
|
||||
"User Sensor: %s, Side: %s, User: %s",
|
||||
self._sensor,
|
||||
self._user_obj.side,
|
||||
self._user_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | int | float | None:
|
||||
"""Return the state of the sensor."""
|
||||
if not self._user_obj:
|
||||
return None
|
||||
|
||||
if "current" in self._sensor:
|
||||
if "fitness" in self._sensor:
|
||||
return self._user_obj.current_sleep_fitness_score
|
||||
return self._user_obj.current_sleep_score
|
||||
|
||||
if "last" in self._sensor:
|
||||
return self._user_obj.last_sleep_score
|
||||
|
||||
if self._sensor == "bed_temperature":
|
||||
return self._user_obj.current_values["bed_temp"]
|
||||
|
||||
if self._sensor == "sleep_stage":
|
||||
return self._user_obj.current_values["stage"]
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return device state attributes."""
|
||||
attr = None
|
||||
if "current" in self._sensor and self._user_obj:
|
||||
if "fitness" in self._sensor:
|
||||
attr = self._user_obj.current_fitness_values
|
||||
else:
|
||||
attr = self._user_obj.current_values
|
||||
elif "last" in self._sensor and self._user_obj:
|
||||
attr = self._user_obj.last_values
|
||||
|
||||
if attr is None:
|
||||
# Skip attributes if sensor type doesn't support
|
||||
return None
|
||||
|
||||
if "fitness" in self._sensor:
|
||||
state_attr = {
|
||||
ATTR_FIT_DATE: attr["date"],
|
||||
ATTR_FIT_DURATION_SCORE: attr["duration"],
|
||||
ATTR_FIT_ASLEEP_SCORE: attr["asleep"],
|
||||
ATTR_FIT_OUT_SCORE: attr["out"],
|
||||
ATTR_FIT_WAKEUP_SCORE: attr["wakeup"],
|
||||
}
|
||||
return state_attr
|
||||
|
||||
state_attr = {ATTR_SESSION_START: attr["date"]}
|
||||
state_attr[ATTR_TNT] = attr["tnt"]
|
||||
state_attr[ATTR_PROCESSING] = attr["processing"]
|
||||
|
||||
if attr.get("breakdown") is not None:
|
||||
sleep_time = sum(attr["breakdown"].values()) - attr["breakdown"]["awake"]
|
||||
state_attr[ATTR_SLEEP_DUR] = sleep_time
|
||||
state_attr[ATTR_LIGHT_PERC] = _get_breakdown_percent(
|
||||
attr, "light", sleep_time
|
||||
)
|
||||
state_attr[ATTR_DEEP_PERC] = _get_breakdown_percent(
|
||||
attr, "deep", sleep_time
|
||||
)
|
||||
state_attr[ATTR_REM_PERC] = _get_breakdown_percent(attr, "rem", sleep_time)
|
||||
|
||||
room_temp = _get_rounded_value(attr, "room_temp")
|
||||
bed_temp = _get_rounded_value(attr, "bed_temp")
|
||||
|
||||
if "current" in self._sensor:
|
||||
state_attr[ATTR_RESP_RATE] = _get_rounded_value(attr, "resp_rate")
|
||||
state_attr[ATTR_HEART_RATE] = _get_rounded_value(attr, "heart_rate")
|
||||
state_attr[ATTR_SLEEP_STAGE] = attr["stage"]
|
||||
state_attr[ATTR_ROOM_TEMP] = room_temp
|
||||
state_attr[ATTR_BED_TEMP] = bed_temp
|
||||
elif "last" in self._sensor:
|
||||
state_attr[ATTR_AVG_RESP_RATE] = _get_rounded_value(attr, "resp_rate")
|
||||
state_attr[ATTR_AVG_HEART_RATE] = _get_rounded_value(attr, "heart_rate")
|
||||
state_attr[ATTR_AVG_ROOM_TEMP] = room_temp
|
||||
state_attr[ATTR_AVG_BED_TEMP] = bed_temp
|
||||
|
||||
return state_attr
|
||||
|
||||
|
||||
class EightRoomSensor(EightSleepBaseEntity, SensorEntity):
|
||||
"""Representation of an eight sleep room sensor."""
|
||||
|
||||
_attr_icon = "mdi:thermometer"
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
eight: EightSleep,
|
||||
sensor: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(entry, coordinator, eight, None, sensor)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self._eight.room_temperature
|
||||
@@ -1,20 +0,0 @@
|
||||
heat_set:
|
||||
target:
|
||||
entity:
|
||||
integration: eight_sleep
|
||||
domain: sensor
|
||||
fields:
|
||||
duration:
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 28800
|
||||
unit_of_measurement: seconds
|
||||
target:
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
min: -100
|
||||
max: 100
|
||||
unit_of_measurement: "°"
|
||||
@@ -1,35 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Cannot connect to Eight Sleep cloud: {error}"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:component::eight_sleep::config::error::cannot_connect%]"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"heat_set": {
|
||||
"name": "Heat set",
|
||||
"description": "Sets heating/cooling level for eight sleep.",
|
||||
"fields": {
|
||||
"duration": {
|
||||
"name": "Duration",
|
||||
"description": "Duration to heat/cool at the target level in seconds."
|
||||
},
|
||||
"target": {
|
||||
"name": "Target",
|
||||
"description": "Target cooling/heating level from -100 to 100."
|
||||
}
|
||||
}
|
||||
"issues": {
|
||||
"integration_removed": {
|
||||
"title": "The Eight Sleep integration has been removed",
|
||||
"description": "The Eight Sleep integration has been removed from Home Assistant.\n\nThe Eight Sleep API has changed and now requires a unique secret which is inaccessible outside of their apps.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Eight Sleep integration entries]({entries})."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,19 +676,20 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]:
|
||||
@lru_cache(maxsize=512)
|
||||
def _build_entity_state_dict(entity: State) -> dict[str, Any]:
|
||||
"""Build a state dict for an entity."""
|
||||
is_on = entity.state != STATE_OFF
|
||||
data: dict[str, Any] = {
|
||||
STATE_ON: entity.state != STATE_OFF,
|
||||
STATE_ON: is_on,
|
||||
STATE_BRIGHTNESS: None,
|
||||
STATE_HUE: None,
|
||||
STATE_SATURATION: None,
|
||||
STATE_COLOR_TEMP: None,
|
||||
}
|
||||
if data[STATE_ON]:
|
||||
attributes = entity.attributes
|
||||
if is_on:
|
||||
data[STATE_BRIGHTNESS] = hass_to_hue_brightness(
|
||||
entity.attributes.get(ATTR_BRIGHTNESS, 0)
|
||||
attributes.get(ATTR_BRIGHTNESS) or 0
|
||||
)
|
||||
hue_sat = entity.attributes.get(ATTR_HS_COLOR)
|
||||
if hue_sat is not None:
|
||||
if (hue_sat := attributes.get(ATTR_HS_COLOR)) is not None:
|
||||
hue = hue_sat[0]
|
||||
sat = hue_sat[1]
|
||||
# Convert hass hs values back to hue hs values
|
||||
@@ -697,7 +698,7 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]:
|
||||
else:
|
||||
data[STATE_HUE] = HUE_API_STATE_HUE_MIN
|
||||
data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN
|
||||
data[STATE_COLOR_TEMP] = entity.attributes.get(ATTR_COLOR_TEMP, 0)
|
||||
data[STATE_COLOR_TEMP] = attributes.get(ATTR_COLOR_TEMP) or 0
|
||||
|
||||
else:
|
||||
data[STATE_BRIGHTNESS] = 0
|
||||
@@ -706,25 +707,23 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]:
|
||||
data[STATE_COLOR_TEMP] = 0
|
||||
|
||||
if entity.domain == climate.DOMAIN:
|
||||
temperature = entity.attributes.get(ATTR_TEMPERATURE, 0)
|
||||
temperature = attributes.get(ATTR_TEMPERATURE, 0)
|
||||
# Convert 0-100 to 0-254
|
||||
data[STATE_BRIGHTNESS] = round(temperature * HUE_API_STATE_BRI_MAX / 100)
|
||||
elif entity.domain == humidifier.DOMAIN:
|
||||
humidity = entity.attributes.get(ATTR_HUMIDITY, 0)
|
||||
humidity = attributes.get(ATTR_HUMIDITY, 0)
|
||||
# Convert 0-100 to 0-254
|
||||
data[STATE_BRIGHTNESS] = round(humidity * HUE_API_STATE_BRI_MAX / 100)
|
||||
elif entity.domain == media_player.DOMAIN:
|
||||
level = entity.attributes.get(
|
||||
ATTR_MEDIA_VOLUME_LEVEL, 1.0 if data[STATE_ON] else 0.0
|
||||
)
|
||||
level = attributes.get(ATTR_MEDIA_VOLUME_LEVEL, 1.0 if is_on else 0.0)
|
||||
# Convert 0.0-1.0 to 0-254
|
||||
data[STATE_BRIGHTNESS] = round(min(1.0, level) * HUE_API_STATE_BRI_MAX)
|
||||
elif entity.domain == fan.DOMAIN:
|
||||
percentage = entity.attributes.get(ATTR_PERCENTAGE) or 0
|
||||
percentage = attributes.get(ATTR_PERCENTAGE) or 0
|
||||
# Convert 0-100 to 0-254
|
||||
data[STATE_BRIGHTNESS] = round(percentage * HUE_API_STATE_BRI_MAX / 100)
|
||||
elif entity.domain == cover.DOMAIN:
|
||||
level = entity.attributes.get(ATTR_CURRENT_POSITION, 0)
|
||||
level = attributes.get(ATTR_CURRENT_POSITION, 0)
|
||||
data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX)
|
||||
_clamp_values(data)
|
||||
return data
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"requirements": ["pyenphase==1.13.1"],
|
||||
"requirements": ["pyenphase==1.14.3"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -75,15 +75,13 @@ def verify_connected(func: _WrapFuncType) -> _WrapFuncType:
|
||||
self: ESPHomeClient, *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
# pylint: disable=protected-access
|
||||
if not self._is_connected:
|
||||
raise BleakError(f"{self._description} is not connected")
|
||||
loop = self._loop
|
||||
disconnected_futures = self._disconnected_futures
|
||||
disconnected_future = loop.create_future()
|
||||
disconnected_futures.add(disconnected_future)
|
||||
ble_device = self._ble_device
|
||||
disconnect_message = (
|
||||
f"{self._source_name }: {ble_device.name} - {ble_device.address}: "
|
||||
"Disconnected during operation"
|
||||
)
|
||||
disconnect_message = f"{self._description}: Disconnected during operation"
|
||||
try:
|
||||
async with interrupt(disconnected_future, BleakError, disconnect_message):
|
||||
return await func(self, *args, **kwargs)
|
||||
@@ -115,10 +113,8 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType:
|
||||
if ex.error.error == -1:
|
||||
# pylint: disable=protected-access
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: BLE device disconnected during %s operation",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
"%s: BLE device disconnected during %s operation",
|
||||
self._description,
|
||||
func.__name__,
|
||||
)
|
||||
self._async_ble_device_disconnected()
|
||||
@@ -140,7 +136,7 @@ class ESPHomeClientData:
|
||||
api_version: APIVersion
|
||||
title: str
|
||||
scanner: ESPHomeScanner | None
|
||||
disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
||||
disconnect_callbacks: set[Callable[[], None]] = field(default_factory=set)
|
||||
|
||||
|
||||
class ESPHomeClient(BaseBleakClient):
|
||||
@@ -159,10 +155,11 @@ class ESPHomeClient(BaseBleakClient):
|
||||
assert isinstance(address_or_ble_device, BLEDevice)
|
||||
super().__init__(address_or_ble_device, *args, **kwargs)
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._ble_device = address_or_ble_device
|
||||
self._address_as_int = mac_to_int(self._ble_device.address)
|
||||
assert self._ble_device.details is not None
|
||||
self._source = self._ble_device.details["source"]
|
||||
ble_device = address_or_ble_device
|
||||
self._ble_device = ble_device
|
||||
self._address_as_int = mac_to_int(ble_device.address)
|
||||
assert ble_device.details is not None
|
||||
self._source = ble_device.details["source"]
|
||||
self._cache = client_data.cache
|
||||
self._bluetooth_device = client_data.bluetooth_device
|
||||
self._client = client_data.client
|
||||
@@ -177,8 +174,11 @@ class ESPHomeClient(BaseBleakClient):
|
||||
self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat(
|
||||
client_data.api_version
|
||||
)
|
||||
self._address_type = address_or_ble_device.details["address_type"]
|
||||
self._address_type = ble_device.details["address_type"]
|
||||
self._source_name = f"{client_data.title} [{self._source}]"
|
||||
self._description = (
|
||||
f"{self._source_name}: {ble_device.name} - {ble_device.address}"
|
||||
)
|
||||
scanner = client_data.scanner
|
||||
assert scanner is not None
|
||||
self._scanner = scanner
|
||||
@@ -196,12 +196,10 @@ class ESPHomeClient(BaseBleakClient):
|
||||
except (AssertionError, ValueError) as ex:
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"%s: %s - %s: Failed to unsubscribe from connection state (likely"
|
||||
"%s: Failed to unsubscribe from connection state (likely"
|
||||
" connection dropped): %s"
|
||||
),
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
self._description,
|
||||
ex,
|
||||
)
|
||||
self._cancel_connection_state = None
|
||||
@@ -217,6 +215,7 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if not future.done():
|
||||
future.set_result(None)
|
||||
self._disconnected_futures.clear()
|
||||
self._disconnect_callbacks.discard(self._async_esp_disconnected)
|
||||
self._unsubscribe_connection_state()
|
||||
|
||||
def _async_ble_device_disconnected(self) -> None:
|
||||
@@ -224,23 +223,15 @@ class ESPHomeClient(BaseBleakClient):
|
||||
was_connected = self._is_connected
|
||||
self._async_disconnected_cleanup()
|
||||
if was_connected:
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: BLE device disconnected",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
_LOGGER.debug("%s: BLE device disconnected", self._description)
|
||||
self._async_call_bleak_disconnected_callback()
|
||||
|
||||
def _async_esp_disconnected(self) -> None:
|
||||
"""Handle the esp32 client disconnecting from us."""
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: ESP device disconnected",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
self._disconnect_callbacks.remove(self._async_esp_disconnected)
|
||||
_LOGGER.debug("%s: ESP device disconnected", self._description)
|
||||
# Calling _async_ble_device_disconnected calls
|
||||
# _async_disconnected_cleanup which will also remove
|
||||
# the disconnect callbacks
|
||||
self._async_ble_device_disconnected()
|
||||
|
||||
def _async_call_bleak_disconnected_callback(self) -> None:
|
||||
@@ -258,10 +249,8 @@ class ESPHomeClient(BaseBleakClient):
|
||||
) -> None:
|
||||
"""Handle a connect or disconnect."""
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Connection state changed to connected=%s mtu=%s error=%s",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
"%s: Connection state changed to connected=%s mtu=%s error=%s",
|
||||
self._description,
|
||||
connected,
|
||||
mtu,
|
||||
error,
|
||||
@@ -300,12 +289,10 @@ class ESPHomeClient(BaseBleakClient):
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: connected, registering for disconnected callbacks",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
"%s: connected, registering for disconnected callbacks",
|
||||
self._description,
|
||||
)
|
||||
self._disconnect_callbacks.append(self._async_esp_disconnected)
|
||||
self._disconnect_callbacks.add(self._async_esp_disconnected)
|
||||
connected_future.set_result(connected)
|
||||
|
||||
@api_error_as_bleak_error
|
||||
@@ -403,10 +390,8 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if bluetooth_device.ble_connections_free:
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Out of connection slots, waiting for a free one",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
"%s: Out of connection slots, waiting for a free one",
|
||||
self._description,
|
||||
)
|
||||
async with asyncio.timeout(timeout):
|
||||
await bluetooth_device.wait_for_ble_connections_free()
|
||||
@@ -434,7 +419,7 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if response.paired:
|
||||
return True
|
||||
_LOGGER.error(
|
||||
"Pairing with %s failed due to error: %s", self.address, response.error
|
||||
"%s: Pairing failed due to error: %s", self._description, response.error
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -451,7 +436,7 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if response.success:
|
||||
return True
|
||||
_LOGGER.error(
|
||||
"Unpairing with %s failed due to error: %s", self.address, response.error
|
||||
"%s: Unpairing failed due to error: %s", self._description, response.error
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -486,30 +471,14 @@ class ESPHomeClient(BaseBleakClient):
|
||||
self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING
|
||||
or dangerous_use_bleak_cache
|
||||
) and (cached_services := cache.get_gatt_services_cache(address_as_int)):
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Cached services hit",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
_LOGGER.debug("%s: Cached services hit", self._description)
|
||||
self.services = cached_services
|
||||
return self.services
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Cached services miss",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
_LOGGER.debug("%s: Cached services miss", self._description)
|
||||
esphome_services = await self._client.bluetooth_gatt_get_services(
|
||||
address_as_int
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Got services: %s",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
esphome_services,
|
||||
)
|
||||
_LOGGER.debug("%s: Got services: %s", self._description, esphome_services)
|
||||
max_write_without_response = self.mtu_size - GATT_HEADER_SIZE
|
||||
services = BleakGATTServiceCollection() # type: ignore[no-untyped-call]
|
||||
for service in esphome_services.services:
|
||||
@@ -538,12 +507,7 @@ class ESPHomeClient(BaseBleakClient):
|
||||
raise BleakError("Failed to get services from remote esp")
|
||||
|
||||
self.services = services
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Cached services saved",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
_LOGGER.debug("%s: Cached services saved", self._description)
|
||||
cache.set_gatt_services_cache(address_as_int, services)
|
||||
return services
|
||||
|
||||
@@ -552,13 +516,15 @@ class ESPHomeClient(BaseBleakClient):
|
||||
) -> BleakGATTCharacteristic:
|
||||
"""Resolve a characteristic specifier to a BleakGATTCharacteristic object."""
|
||||
if (services := self.services) is None:
|
||||
raise BleakError("Services have not been resolved")
|
||||
raise BleakError(f"{self._description}: Services have not been resolved")
|
||||
if not isinstance(char_specifier, BleakGATTCharacteristic):
|
||||
characteristic = services.get_characteristic(char_specifier)
|
||||
else:
|
||||
characteristic = char_specifier
|
||||
if not characteristic:
|
||||
raise BleakError(f"Characteristic {char_specifier} was not found!")
|
||||
raise BleakError(
|
||||
f"{self._description}: Characteristic {char_specifier} was not found!"
|
||||
)
|
||||
return characteristic
|
||||
|
||||
@verify_connected
|
||||
@@ -579,8 +545,8 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if response.success:
|
||||
return True
|
||||
_LOGGER.error(
|
||||
"Clear cache failed with %s failed due to error: %s",
|
||||
self.address,
|
||||
"%s: Clear cache failed due to error: %s",
|
||||
self._description,
|
||||
response.error,
|
||||
)
|
||||
return False
|
||||
@@ -692,7 +658,7 @@ class ESPHomeClient(BaseBleakClient):
|
||||
ble_handle = characteristic.handle
|
||||
if ble_handle in self._notify_cancels:
|
||||
raise BleakError(
|
||||
"Notifications are already enabled on "
|
||||
f"{self._description}: Notifications are already enabled on "
|
||||
f"service:{characteristic.service_uuid} "
|
||||
f"characteristic:{characteristic.uuid} "
|
||||
f"handle:{ble_handle}"
|
||||
@@ -702,8 +668,8 @@ class ESPHomeClient(BaseBleakClient):
|
||||
and "indicate" not in characteristic.properties
|
||||
):
|
||||
raise BleakError(
|
||||
f"Characteristic {characteristic.uuid} does not have notify or indicate"
|
||||
" property set."
|
||||
f"{self._description}: Characteristic {characteristic.uuid} "
|
||||
"does not have notify or indicate property set."
|
||||
)
|
||||
|
||||
self._notify_cancels[
|
||||
@@ -725,18 +691,13 @@ class ESPHomeClient(BaseBleakClient):
|
||||
cccd_descriptor = characteristic.get_descriptor(CCCD_UUID)
|
||||
if not cccd_descriptor:
|
||||
raise BleakError(
|
||||
f"Characteristic {characteristic.uuid} does not have a "
|
||||
"characteristic client config descriptor."
|
||||
f"{self._description}: Characteristic {characteristic.uuid} "
|
||||
"does not have a characteristic client config descriptor."
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"%s: %s - %s: Writing to CCD descriptor %s for notifications with"
|
||||
" properties=%s"
|
||||
),
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
"%s: Writing to CCD descriptor %s for notifications with properties=%s",
|
||||
self._description,
|
||||
cccd_descriptor.handle,
|
||||
characteristic.properties,
|
||||
)
|
||||
@@ -774,12 +735,10 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if self._cancel_connection_state:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s: %s - %s: ESPHomeClient bleak client was not properly"
|
||||
"%s: ESPHomeClient bleak client was not properly"
|
||||
" disconnected before destruction"
|
||||
),
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
self._description,
|
||||
)
|
||||
if not self._loop.is_closed():
|
||||
self._loop.call_soon_threadsafe(self._async_disconnected_cleanup)
|
||||
|
||||
@@ -29,6 +29,7 @@ from aioesphomeapi import (
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
SwitchInfo,
|
||||
TextInfo,
|
||||
TextSensorInfo,
|
||||
UserService,
|
||||
build_unique_id,
|
||||
@@ -68,6 +69,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
|
||||
SelectInfo: Platform.SELECT,
|
||||
SensorInfo: Platform.SENSOR,
|
||||
SwitchInfo: Platform.SWITCH,
|
||||
TextInfo: Platform.TEXT,
|
||||
TextSensorInfo: Platform.SENSOR,
|
||||
}
|
||||
|
||||
@@ -105,7 +107,7 @@ class RuntimeEntryData:
|
||||
bluetooth_device: ESPHomeBluetoothDevice | None = None
|
||||
api_version: APIVersion = field(default_factory=APIVersion)
|
||||
cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
||||
disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
||||
disconnect_callbacks: set[Callable[[], None]] = field(default_factory=set)
|
||||
state_subscriptions: dict[
|
||||
tuple[type[EntityState], int], Callable[[], None]
|
||||
] = field(default_factory=dict)
|
||||
@@ -425,3 +427,19 @@ class RuntimeEntryData:
|
||||
if self.original_options == entry.options:
|
||||
return
|
||||
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
|
||||
|
||||
@callback
|
||||
def async_on_disconnect(self) -> None:
|
||||
"""Call when the entry has been disconnected.
|
||||
|
||||
Safe to call multiple times.
|
||||
"""
|
||||
self.available = False
|
||||
# Make a copy since calling the disconnect callbacks
|
||||
# may also try to discard/remove themselves.
|
||||
for disconnect_cb in self.disconnect_callbacks.copy():
|
||||
disconnect_cb()
|
||||
# Make sure to clear the set to give up the reference
|
||||
# to it and make sure all the callbacks can be GC'd.
|
||||
self.disconnect_callbacks.clear()
|
||||
self.disconnect_callbacks = set()
|
||||
|
||||
@@ -294,7 +294,7 @@ class ESPHomeManager:
|
||||
event.data["entity_id"], attribute, new_state
|
||||
)
|
||||
|
||||
self.entry_data.disconnect_callbacks.append(
|
||||
self.entry_data.disconnect_callbacks.add(
|
||||
async_track_state_change_event(
|
||||
hass, [entity_id], send_home_assistant_state_event
|
||||
)
|
||||
@@ -439,7 +439,7 @@ class ESPHomeManager:
|
||||
reconnect_logic.name = device_info.name
|
||||
|
||||
if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version):
|
||||
entry_data.disconnect_callbacks.append(
|
||||
entry_data.disconnect_callbacks.add(
|
||||
await async_connect_scanner(
|
||||
hass, entry, cli, entry_data, self.domain_data.bluetooth_cache
|
||||
)
|
||||
@@ -459,7 +459,7 @@ class ESPHomeManager:
|
||||
await cli.subscribe_home_assistant_states(self.async_on_state_subscription)
|
||||
|
||||
if device_info.voice_assistant_version:
|
||||
entry_data.disconnect_callbacks.append(
|
||||
entry_data.disconnect_callbacks.add(
|
||||
await cli.subscribe_voice_assistant(
|
||||
self._handle_pipeline_start,
|
||||
self._handle_pipeline_stop,
|
||||
@@ -487,10 +487,7 @@ class ESPHomeManager:
|
||||
host,
|
||||
expected_disconnect,
|
||||
)
|
||||
for disconnect_cb in entry_data.disconnect_callbacks:
|
||||
disconnect_cb()
|
||||
entry_data.disconnect_callbacks = []
|
||||
entry_data.available = False
|
||||
entry_data.async_on_disconnect()
|
||||
entry_data.expected_disconnect = expected_disconnect
|
||||
# Mark state as stale so that we will always dispatch
|
||||
# the next state update of that type when the device reconnects
|
||||
@@ -596,6 +593,10 @@ def _async_setup_device_registry(
|
||||
model = project_name[1]
|
||||
hw_version = device_info.project_version
|
||||
|
||||
suggested_area = None
|
||||
if device_info.suggested_area:
|
||||
suggested_area = device_info.suggested_area
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
@@ -606,6 +607,7 @@ def _async_setup_device_registry(
|
||||
model=model,
|
||||
sw_version=sw_version,
|
||||
hw_version=hw_version,
|
||||
suggested_area=suggested_area,
|
||||
)
|
||||
return device_entry.id
|
||||
|
||||
@@ -750,10 +752,7 @@ async def cleanup_instance(hass: HomeAssistant, entry: ConfigEntry) -> RuntimeEn
|
||||
"""Cleanup the esphome client if it exists."""
|
||||
domain_data = DomainData.get(hass)
|
||||
data = domain_data.pop_entry_data(entry)
|
||||
data.available = False
|
||||
for disconnect_cb in data.disconnect_callbacks:
|
||||
disconnect_cb()
|
||||
data.disconnect_callbacks = []
|
||||
data.async_on_disconnect()
|
||||
for cleanup_callback in data.cleanup_callbacks:
|
||||
cleanup_callback()
|
||||
await data.async_cleanup()
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
"loggers": ["aioesphomeapi", "noiseprotocol"],
|
||||
"requirements": [
|
||||
"async-interrupt==1.1.1",
|
||||
"aioesphomeapi==18.0.10",
|
||||
"bluetooth-data-tools==1.13.0",
|
||||
"aioesphomeapi==18.2.4",
|
||||
"bluetooth-data-tools==1.14.0",
|
||||
"esphome-dashboard-api==1.2.3"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Support for esphome texts."""
|
||||
from __future__ import annotations
|
||||
|
||||
from aioesphomeapi import EntityInfo, TextInfo, TextMode as EsphomeTextMode, TextState
|
||||
|
||||
from homeassistant.components.text import TextEntity, TextMode
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry
|
||||
from .enum_mapper import EsphomeEnumMapper
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up esphome texts based on a config entry."""
|
||||
await platform_async_setup_entry(
|
||||
hass,
|
||||
entry,
|
||||
async_add_entities,
|
||||
info_type=TextInfo,
|
||||
entity_type=EsphomeText,
|
||||
state_type=TextState,
|
||||
)
|
||||
|
||||
|
||||
TEXT_MODES: EsphomeEnumMapper[EsphomeTextMode, TextMode] = EsphomeEnumMapper(
|
||||
{
|
||||
EsphomeTextMode.TEXT: TextMode.TEXT,
|
||||
EsphomeTextMode.PASSWORD: TextMode.PASSWORD,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity):
|
||||
"""A text implementation for esphome."""
|
||||
|
||||
@callback
|
||||
def _on_static_info_update(self, static_info: EntityInfo) -> None:
|
||||
"""Set attrs from static info."""
|
||||
super()._on_static_info_update(static_info)
|
||||
static_info = self._static_info
|
||||
self._attr_native_min = static_info.min_length
|
||||
self._attr_native_max = static_info.max_length
|
||||
self._attr_pattern = static_info.pattern
|
||||
self._attr_mode = TEXT_MODES.from_esphome(static_info.mode) or TextMode.TEXT
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the state of the entity."""
|
||||
state = self._state
|
||||
if state.missing_state:
|
||||
return None
|
||||
return state.state
|
||||
|
||||
async def async_set_value(self, value: str) -> None:
|
||||
"""Update the current value."""
|
||||
await self._client.text_command(self._key, value)
|
||||
@@ -487,6 +487,18 @@ class EvoBroker:
|
||||
)
|
||||
self.temps = None # these are now stale, will fall back to v2 temps
|
||||
|
||||
except KeyError as err:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"Unable to obtain high-precision temperatures. "
|
||||
"It appears the JSON schema is not as expected, "
|
||||
"so the high-precision feature will be disabled until next restart."
|
||||
"Message is: %s"
|
||||
),
|
||||
err,
|
||||
)
|
||||
self.client_v1 = self.temps = None
|
||||
|
||||
else:
|
||||
if (
|
||||
str(self.client_v1.location_id)
|
||||
@@ -495,7 +507,7 @@ class EvoBroker:
|
||||
_LOGGER.warning(
|
||||
"The v2 API's configured location doesn't match "
|
||||
"the v1 API's default location (there is more than one location), "
|
||||
"so the high-precision feature will be disabled"
|
||||
"so the high-precision feature will be disabled until next restart"
|
||||
)
|
||||
self.client_v1 = self.temps = None
|
||||
else:
|
||||
|
||||
@@ -3,14 +3,24 @@ from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_automation import toggle_entity
|
||||
from homeassistant.components.device_automation import (
|
||||
async_validate_entity_schema,
|
||||
toggle_entity,
|
||||
)
|
||||
from homeassistant.const import CONF_DOMAIN
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN})
|
||||
_ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN})
|
||||
|
||||
|
||||
async def async_validate_action_config(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return async_validate_entity_schema(hass, config, _ACTION_SCHEMA)
|
||||
|
||||
|
||||
async def async_get_actions(
|
||||
|
||||
@@ -69,7 +69,7 @@ class FitbitApi(ABC):
|
||||
profile = response["user"]
|
||||
self._profile = FitbitProfile(
|
||||
encoded_id=profile["encodedId"],
|
||||
full_name=profile["fullName"],
|
||||
display_name=profile["displayName"],
|
||||
locale=profile.get("locale"),
|
||||
)
|
||||
return self._profile
|
||||
|
||||
@@ -59,13 +59,16 @@ class FitbitOAuth2Implementation(AuthImplementation):
|
||||
resp = await session.post(self.token_url, data=data, headers=self._headers)
|
||||
resp.raise_for_status()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
error_body = await resp.text()
|
||||
_LOGGER.debug("Client response error body: %s", error_body)
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
error_body = await resp.text() if not session.closed else ""
|
||||
_LOGGER.debug(
|
||||
"Client response error status=%s, body=%s", err.status, error_body
|
||||
)
|
||||
if err.status == HTTPStatus.UNAUTHORIZED:
|
||||
raise FitbitAuthException from err
|
||||
raise FitbitApiException from err
|
||||
raise FitbitAuthException(f"Unauthorized error: {err}") from err
|
||||
raise FitbitApiException(f"Server error response: {err}") from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise FitbitApiException from err
|
||||
raise FitbitApiException(f"Client connection error: {err}") from err
|
||||
return cast(dict, await resp.json())
|
||||
|
||||
@property
|
||||
|
||||
@@ -53,6 +53,21 @@ class OAuth2FlowHandler(
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_creation(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Create config entry from external data with Fitbit specific error handling."""
|
||||
try:
|
||||
return await super().async_step_creation()
|
||||
except FitbitAuthException as err:
|
||||
_LOGGER.error(
|
||||
"Failed to authenticate when creating Fitbit credentials: %s", err
|
||||
)
|
||||
return self.async_abort(reason="invalid_auth")
|
||||
except FitbitApiException as err:
|
||||
_LOGGER.error("Failed to create Fitbit credentials: %s", err)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
|
||||
"""Create an entry for the flow, or update existing entry."""
|
||||
|
||||
@@ -75,7 +90,7 @@ class OAuth2FlowHandler(
|
||||
|
||||
await self.async_set_unique_id(profile.encoded_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=profile.full_name, data=data)
|
||||
return self.async_create_entry(title=profile.display_name, data=data)
|
||||
|
||||
async def async_step_import(self, data: dict[str, Any]) -> FlowResult:
|
||||
"""Handle import from YAML."""
|
||||
|
||||
@@ -14,8 +14,8 @@ class FitbitProfile:
|
||||
encoded_id: str
|
||||
"""The ID representing the Fitbit user."""
|
||||
|
||||
full_name: str
|
||||
"""The first name value specified in the user's account settings."""
|
||||
display_name: str
|
||||
"""The name shown when the user's friends look at their Fitbit profile."""
|
||||
|
||||
locale: str | None
|
||||
"""The locale defined in the user's Fitbit account settings."""
|
||||
|
||||
@@ -8,6 +8,8 @@ import logging
|
||||
import os
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from fitbit import Fitbit
|
||||
from oauthlib.oauth2.rfc6749.errors import OAuth2Error
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.application_credentials import (
|
||||
@@ -132,6 +134,17 @@ def _water_unit(unit_system: FitbitUnitSystem) -> UnitOfVolume:
|
||||
return UnitOfVolume.MILLILITERS
|
||||
|
||||
|
||||
def _int_value_or_none(field: str) -> Callable[[dict[str, Any]], int | None]:
|
||||
"""Value function that will parse the specified field if present."""
|
||||
|
||||
def convert(result: dict[str, Any]) -> int | None:
|
||||
if (value := result["value"].get(field)) is not None:
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
return convert
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitbitSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Fitbit sensor entity."""
|
||||
@@ -204,7 +217,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||
name="Resting Heart Rate",
|
||||
native_unit_of_measurement="bpm",
|
||||
icon="mdi:heart-pulse",
|
||||
value_fn=lambda result: int(result["value"]["restingHeartRate"]),
|
||||
value_fn=_int_value_or_none("restingHeartRate"),
|
||||
scope=FitbitScope.HEART_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
@@ -567,34 +580,54 @@ async def async_setup_platform(
|
||||
|
||||
if config_file is not None:
|
||||
_LOGGER.debug("Importing existing fitbit.conf application credentials")
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential(
|
||||
config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET]
|
||||
),
|
||||
|
||||
# Refresh the token before importing to ensure it is working and not
|
||||
# expired on first initialization.
|
||||
authd_client = Fitbit(
|
||||
config_file[CONF_CLIENT_ID],
|
||||
config_file[CONF_CLIENT_SECRET],
|
||||
access_token=config_file[ATTR_ACCESS_TOKEN],
|
||||
refresh_token=config_file[ATTR_REFRESH_TOKEN],
|
||||
expires_at=config_file[ATTR_LAST_SAVED_AT],
|
||||
refresh_cb=lambda x: None,
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
CONF_TOKEN: {
|
||||
ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN],
|
||||
ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN],
|
||||
"expires_at": config_file[ATTR_LAST_SAVED_AT],
|
||||
},
|
||||
CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT],
|
||||
CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM],
|
||||
CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES],
|
||||
},
|
||||
)
|
||||
translation_key = "deprecated_yaml_import"
|
||||
if (
|
||||
result.get("type") == FlowResultType.ABORT
|
||||
and result.get("reason") == "cannot_connect"
|
||||
):
|
||||
try:
|
||||
updated_token = await hass.async_add_executor_job(
|
||||
authd_client.client.refresh_token
|
||||
)
|
||||
except OAuth2Error as err:
|
||||
_LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err)
|
||||
translation_key = "deprecated_yaml_import_issue_cannot_connect"
|
||||
else:
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential(
|
||||
config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET]
|
||||
),
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
CONF_TOKEN: {
|
||||
ATTR_ACCESS_TOKEN: updated_token[ATTR_ACCESS_TOKEN],
|
||||
ATTR_REFRESH_TOKEN: updated_token[ATTR_REFRESH_TOKEN],
|
||||
"expires_at": updated_token["expires_at"],
|
||||
"scope": " ".join(updated_token.get("scope", [])),
|
||||
},
|
||||
CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT],
|
||||
CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM],
|
||||
CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES],
|
||||
},
|
||||
)
|
||||
translation_key = "deprecated_yaml_import"
|
||||
if (
|
||||
result.get("type") == FlowResultType.ABORT
|
||||
and result.get("reason") == "cannot_connect"
|
||||
):
|
||||
translation_key = "deprecated_yaml_import_issue_cannot_connect"
|
||||
else:
|
||||
translation_key = "deprecated_yaml_no_import"
|
||||
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"wrong_account": "The user credentials provided do not match this Fitbit account."
|
||||
|
||||
@@ -139,9 +139,9 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
return self._device_information["fwVersion"]
|
||||
|
||||
@property
|
||||
def serial_number(self) -> str:
|
||||
def serial_number(self) -> str | None:
|
||||
"""Return the serial number for the device."""
|
||||
return self._device_information["serialNumber"]
|
||||
return self._device_information.get("serialNumber")
|
||||
|
||||
@property
|
||||
def pending_info_alerts_count(self) -> int:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from pyflume import FlumeAuth, FlumeDeviceList
|
||||
from requests import Session
|
||||
from requests.exceptions import RequestException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -10,8 +11,14 @@ from homeassistant.const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
|
||||
from .const import (
|
||||
BASE_TOKEN_FILENAME,
|
||||
@@ -19,8 +26,18 @@ from .const import (
|
||||
FLUME_AUTH,
|
||||
FLUME_DEVICES,
|
||||
FLUME_HTTP_SESSION,
|
||||
FLUME_NOTIFICATIONS_COORDINATOR,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import FlumeNotificationDataUpdateCoordinator
|
||||
|
||||
SERVICE_LIST_NOTIFICATIONS = "list_notifications"
|
||||
CONF_CONFIG_ENTRY = "config_entry"
|
||||
LIST_NOTIFICATIONS_SERVICE_SCHEMA = vol.All(
|
||||
{
|
||||
vol.Required(CONF_CONFIG_ENTRY): ConfigEntrySelector(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
@@ -59,14 +76,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
flume_auth, flume_devices, http_session = await hass.async_add_executor_job(
|
||||
_setup_entry, hass, entry
|
||||
)
|
||||
notification_coordinator = FlumeNotificationDataUpdateCoordinator(
|
||||
hass=hass, auth=flume_auth
|
||||
)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
|
||||
FLUME_DEVICES: flume_devices,
|
||||
FLUME_AUTH: flume_auth,
|
||||
FLUME_HTTP_SESSION: http_session,
|
||||
FLUME_NOTIFICATIONS_COORDINATOR: notification_coordinator,
|
||||
}
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await async_setup_service(hass)
|
||||
|
||||
return True
|
||||
|
||||
@@ -81,3 +103,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_setup_service(hass: HomeAssistant) -> None:
|
||||
"""Add the services for the flume integration."""
|
||||
|
||||
async def list_notifications(call: ServiceCall) -> ServiceResponse:
|
||||
"""Return the user notifications."""
|
||||
entry_id: str = call.data[CONF_CONFIG_ENTRY]
|
||||
entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id)
|
||||
if not entry:
|
||||
raise ValueError(f"Invalid config entry: {entry_id}")
|
||||
if not (flume_domain_data := hass.data[DOMAIN].get(entry_id)):
|
||||
raise ValueError(f"Config entry not loaded: {entry_id}")
|
||||
return {
|
||||
"notifications": flume_domain_data[
|
||||
FLUME_NOTIFICATIONS_COORDINATOR
|
||||
].notifications
|
||||
}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_LIST_NOTIFICATIONS,
|
||||
list_notifications,
|
||||
schema=LIST_NOTIFICATIONS_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
@@ -15,8 +15,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FLUME_AUTH,
|
||||
FLUME_DEVICES,
|
||||
FLUME_NOTIFICATIONS_COORDINATOR,
|
||||
FLUME_TYPE_BRIDGE,
|
||||
FLUME_TYPE_SENSOR,
|
||||
KEY_DEVICE_ID,
|
||||
@@ -84,7 +84,6 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up a Flume binary sensor.."""
|
||||
flume_domain_data = hass.data[DOMAIN][config_entry.entry_id]
|
||||
flume_auth = flume_domain_data[FLUME_AUTH]
|
||||
flume_devices = flume_domain_data[FLUME_DEVICES]
|
||||
|
||||
flume_entity_list: list[
|
||||
@@ -94,9 +93,7 @@ async def async_setup_entry(
|
||||
connection_coordinator = FlumeDeviceConnectionUpdateCoordinator(
|
||||
hass=hass, flume_devices=flume_devices
|
||||
)
|
||||
notification_coordinator = FlumeNotificationDataUpdateCoordinator(
|
||||
hass=hass, auth=flume_auth
|
||||
)
|
||||
notification_coordinator = flume_domain_data[FLUME_NOTIFICATIONS_COORDINATOR]
|
||||
flume_devices = get_valid_flume_devices(flume_devices)
|
||||
for device in flume_devices:
|
||||
device_id = device[KEY_DEVICE_ID]
|
||||
|
||||
@@ -29,7 +29,7 @@ FLUME_TYPE_SENSOR = 2
|
||||
FLUME_AUTH = "flume_auth"
|
||||
FLUME_HTTP_SESSION = "http_session"
|
||||
FLUME_DEVICES = "devices"
|
||||
|
||||
FLUME_NOTIFICATIONS_COORDINATOR = "notifications_coordinator"
|
||||
|
||||
CONF_TOKEN_FILE = "token_filename"
|
||||
BASE_TOKEN_FILENAME = "FLUME_TOKEN_FILE"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
list_notifications:
|
||||
fields:
|
||||
config_entry:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: flume
|
||||
@@ -61,5 +61,17 @@
|
||||
"name": "30 days"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"list_notifications": {
|
||||
"name": "List notifications",
|
||||
"description": "Return user notifications.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "Flume",
|
||||
"description": "The flume config entry for which to return notifications."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user