mirror of
https://github.com/home-assistant/core.git
synced 2026-05-22 00:35:16 +02:00
Compare commits
593 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e08ba030a0 | |||
| 39066b6e3a | |||
| a23a9b350b | |||
| fdaa807ca8 | |||
| f290dcc03f | |||
| 654408cc76 | |||
| 1f814faad8 | |||
| 6e00eecfcd | |||
| 8c8620c511 | |||
| cca8825ca5 | |||
| 92fbcc29a5 | |||
| 1c28833f39 | |||
| cfdef77222 | |||
| 49720475da | |||
| 7967b84cc6 | |||
| c715557813 | |||
| 79e5330782 | |||
| 5210ca64b1 | |||
| 65283e3d77 | |||
| 427cb9f8db | |||
| a09e042d42 | |||
| 072e9b51a2 | |||
| b96342c4f3 | |||
| 56eae8c808 | |||
| 9fbdf86104 | |||
| 8ff5da59c4 | |||
| 298f4f8ed0 | |||
| 6fdc0bb90b | |||
| 94c3ad2cb2 | |||
| d83d44648c | |||
| 279b614b7c | |||
| 244dfe014a | |||
| 6b379e50cf | |||
| 1368cd15da | |||
| 8c8cc3acb9 | |||
| b0634bea35 | |||
| 5ae31cad6f | |||
| b45aaaa177 | |||
| 6560496440 | |||
| 489dda8efb | |||
| 30c942d139 | |||
| c735e47e23 | |||
| 3856405c72 | |||
| 323479ca44 | |||
| c8bfe56975 | |||
| ab214b64f2 | |||
| fea673d93a | |||
| 5405151112 | |||
| b3c210ef24 | |||
| 5f5d74cfbd | |||
| c188fdcc8b | |||
| a3b43fc19b | |||
| 894a68acb6 | |||
| 30bc3fc412 | |||
| 3cc0cc38ab | |||
| 296caa90c1 | |||
| bb4c211fb6 | |||
| d4fa904386 | |||
| db98f0b434 | |||
| 7341ac91ee | |||
| b2fb5df0fb | |||
| 265485a7d0 | |||
| bf1b93fb66 | |||
| be9d4bedfd | |||
| e8ac982e83 | |||
| 6c8e5a8e98 | |||
| e40a3e18db | |||
| cba05caadd | |||
| ef3bc61e2b | |||
| 3eff36eb9d | |||
| 8402a4d876 | |||
| 6159516dc0 | |||
| 8fd3dcc7b1 | |||
| b724e52408 | |||
| 1654f7b0f7 | |||
| c8b23d52ba | |||
| 75d2babe65 | |||
| 39ad57acfd | |||
| 162729a176 | |||
| 376d94e7e1 | |||
| c3223b29a4 | |||
| 073ee88a64 | |||
| ef8b4f2d7f | |||
| 0df063b420 | |||
| 79fe415d6c | |||
| 00e3a909a0 | |||
| a85fb79331 | |||
| 7f2f268fca | |||
| 4c31a1737d | |||
| abd8d85225 | |||
| 626a1a5c87 | |||
| 1b2e8ccc0f | |||
| 4da2cd465a | |||
| e3c31a3482 | |||
| a0b52e0f58 | |||
| 75dd509c7b | |||
| 6540ccd52a | |||
| a35ad41495 | |||
| cedf5a5861 | |||
| 16f4dc74bf | |||
| c5f22936e4 | |||
| aa23b3176c | |||
| a144bbab2b | |||
| 6a20b99252 | |||
| 8a12c06116 | |||
| 5dc057b36d | |||
| 6d6f14a0aa | |||
| 7bd81aeb9f | |||
| 8dc29b5411 | |||
| 1cabcf522e | |||
| ff8d244839 | |||
| d1a5b0dbd3 | |||
| 86bab0c0f6 | |||
| 7f320a5a41 | |||
| 309ce5545e | |||
| 293d7851ba | |||
| eeb9270241 | |||
| b841a26aff | |||
| 40f7a2f50f | |||
| 15b230c4e7 | |||
| 49a14112b7 | |||
| e59e631a87 | |||
| c1d0c9fb9f | |||
| ee7461ed9c | |||
| 9757f8b574 | |||
| 5ee96a3616 | |||
| 703ac31bd1 | |||
| 52bf0b0ee0 | |||
| 29db335930 | |||
| 8257107462 | |||
| c7618949da | |||
| 592154bd27 | |||
| 7919330ae0 | |||
| 6ffe1bab9a | |||
| 91705ef821 | |||
| e15797af14 | |||
| a966ce4586 | |||
| ee2de6641f | |||
| 5f85ae6f95 | |||
| 275e0b3dd1 | |||
| a9475683e1 | |||
| d4e1a7075e | |||
| 2a3d75eb2b | |||
| 9212d2300c | |||
| 6836b27ba6 | |||
| 8656f52d7a | |||
| 6a07ca93e9 | |||
| 77990c8808 | |||
| e4227ee1d4 | |||
| 87aca7416f | |||
| 1ec6619a20 | |||
| 85013282e4 | |||
| ceab93ab83 | |||
| ac636ce54f | |||
| 3287b01ed1 | |||
| 3acc7d08b3 | |||
| 16eb5dce63 | |||
| 3fee05db71 | |||
| f823ef639a | |||
| da4263b95c | |||
| 29e2184163 | |||
| 816c3ff939 | |||
| 2348ccc76e | |||
| 4202686a0d | |||
| dd1437f5f2 | |||
| 1a1c9d935c | |||
| 4c0e7eb92d | |||
| d288645f0e | |||
| 66aad8d3c5 | |||
| 89e15b9eae | |||
| 489b831a4b | |||
| f1854e1816 | |||
| 8931ce561c | |||
| 4d19cec214 | |||
| e111678c40 | |||
| 69de70407b | |||
| 64d17521a4 | |||
| b52476a37e | |||
| 58c906a2d1 | |||
| 3b2fa3f5b7 | |||
| 0dae4689cf | |||
| cd7fe836b0 | |||
| e3bae0dbda | |||
| 7cf3cba27b | |||
| de70d9ed82 | |||
| eb0c1700b7 | |||
| 6fa5fc77aa | |||
| c705e8ff56 | |||
| ee248b536e | |||
| 7bfd11cf2e | |||
| 2dae262135 | |||
| 76a463dd50 | |||
| 0d83b1cbe8 | |||
| ae622a7cd4 | |||
| 3f0af1e5b7 | |||
| 742e63d02c | |||
| 1042ec2964 | |||
| f4fdd4d58f | |||
| 3963555b2f | |||
| 4f8885b40d | |||
| 3f49877ff1 | |||
| d2bb31d115 | |||
| f499dbf29f | |||
| bc0e3dc3be | |||
| fb6e6170bf | |||
| 9e22711874 | |||
| 1982dd9085 | |||
| c32098decd | |||
| 2e87750d70 | |||
| 55354770a8 | |||
| d7b63a40db | |||
| c80d1ba003 | |||
| e675423c3c | |||
| 11cbf91563 | |||
| 4d5c36a3c1 | |||
| cc335a3bd9 | |||
| f764a32564 | |||
| aeb7109708 | |||
| f75c205c08 | |||
| e20f4c8f6e | |||
| 72f6c38e7d | |||
| 40408def0f | |||
| 282737e3c4 | |||
| a1cc735337 | |||
| b6f4551a76 | |||
| f5d2aa9c12 | |||
| 612dbf2d44 | |||
| f2691e4feb | |||
| f9654e15a6 | |||
| 01dde25ffa | |||
| 34254c138f | |||
| 1076d65c9c | |||
| ad71e31bad | |||
| 7608d5f99d | |||
| cafcbf8179 | |||
| 852faa7f95 | |||
| 5cf1e185f0 | |||
| c4d25a5a26 | |||
| 18f8e11865 | |||
| e8f3d357c4 | |||
| 1ad81697f7 | |||
| f66652c729 | |||
| c468ae77f3 | |||
| 251d7e15d2 | |||
| d268f8b486 | |||
| 6f3dfab487 | |||
| 8d8b9bb2e8 | |||
| 8c9d659dcf | |||
| f08adfe712 | |||
| de29414b37 | |||
| 01d9c2e810 | |||
| 9b3b3eca6d | |||
| 2e45ce36a7 | |||
| fe56ce6813 | |||
| 8000b419ea | |||
| f0a5ce747e | |||
| 7da5b10b51 | |||
| 94b373641d | |||
| dfd241dd1a | |||
| 27b161bf7c | |||
| f2362aa2a3 | |||
| 90946c3e2f | |||
| 318091689c | |||
| ee8c3ca864 | |||
| 5f6f300a20 | |||
| ad04aeced9 | |||
| bbb31f2910 | |||
| 0ed81e426b | |||
| 4582c56c1c | |||
| 9ce3e00e87 | |||
| bd2ea9a148 | |||
| e34be91439 | |||
| 3e5beb9aa3 | |||
| ac5df83d1a | |||
| c9e014c5d8 | |||
| 1b7564dcdf | |||
| 71425dd19f | |||
| eea08a0457 | |||
| 00132b4416 | |||
| 6b9efed899 | |||
| b0b6b46152 | |||
| 044ef25cb6 | |||
| b633fbcf07 | |||
| 7c9b6ad2a8 | |||
| 89d9fff1e9 | |||
| e0af3dfa99 | |||
| 4fb3ad102c | |||
| dc2ab012fa | |||
| 140fef6915 | |||
| 822a567ca9 | |||
| aa8904b0cd | |||
| e9f9194b7b | |||
| d0f4cba32c | |||
| beba530a9a | |||
| 5d3fd5a487 | |||
| bed6af2ef2 | |||
| 2b20b69928 | |||
| d5d50ac11a | |||
| ba5a62ec2a | |||
| 88ca0faea0 | |||
| a333f31d44 | |||
| 8854ad5765 | |||
| 0eecb03b84 | |||
| 0c22c13b1f | |||
| bf56fad3f9 | |||
| 078d40ac54 | |||
| 1b7bda06d3 | |||
| 828dde26e5 | |||
| e8d21e57b3 | |||
| 481965eb0d | |||
| 2fcfa8320f | |||
| 95c68da115 | |||
| d547076033 | |||
| db0006c100 | |||
| f8d4826bf3 | |||
| 88f6b7159a | |||
| f7faed7330 | |||
| 302148b078 | |||
| 5b2816e56c | |||
| f7cf279648 | |||
| ee83a14391 | |||
| 833ff982d0 | |||
| d8cb3ab4b8 | |||
| 23b0f550b1 | |||
| c66eeed8f8 | |||
| bdc9d881ea | |||
| 95e2f5e219 | |||
| 68fc5c0e87 | |||
| 67c1930c6f | |||
| c90017d207 | |||
| 9dce6943de | |||
| 6a5faf2ec7 | |||
| d0711624c0 | |||
| 03ea95dfd4 | |||
| 721c736c03 | |||
| 1c105a5766 | |||
| ba9504a70d | |||
| 5c5d9828f8 | |||
| ad0324631b | |||
| 83fbea2158 | |||
| 74c918b6b6 | |||
| ff7964bcfc | |||
| 9a1fd913bf | |||
| f0396aca8a | |||
| 018e3a4765 | |||
| 2af7f43ed7 | |||
| 95878222fd | |||
| 95f3bd7c09 | |||
| c366beab2e | |||
| 88277d5920 | |||
| 5e0aefd539 | |||
| ff313f1e7f | |||
| 70f9395d02 | |||
| b96f904d15 | |||
| 0d16fa1e65 | |||
| 27816fcb0c | |||
| 4f0faf43c6 | |||
| c28f5d3eed | |||
| 7b589d6ce8 | |||
| b5556e17b2 | |||
| 407d29396a | |||
| 7eaa132189 | |||
| 87b151a436 | |||
| 19cbb3e5c9 | |||
| 675bbd704c | |||
| 30a51e643f | |||
| 6ae50fffe1 | |||
| e60704ccec | |||
| ad9a7c08ab | |||
| 198cb331ed | |||
| fc8949d4a2 | |||
| ed74360485 | |||
| 9109cb5bfb | |||
| 5c29580969 | |||
| 3813843c8c | |||
| 5ac7f898dd | |||
| 85e7c12535 | |||
| 5472a537ed | |||
| a1e22c22d3 | |||
| a69e0ca7c5 | |||
| fb129255b5 | |||
| f521e35e68 | |||
| e7eb508277 | |||
| 816544b5e9 | |||
| e46ad54f51 | |||
| 2aa95b1280 | |||
| 542afb5081 | |||
| 32dde39a77 | |||
| 81280f9cca | |||
| bbd6aa83b1 | |||
| cbcf3f3eb4 | |||
| 362bcf722c | |||
| 54da9649dc | |||
| 31f98c12af | |||
| 5f811e856f | |||
| 4e7bfe2e48 | |||
| 5e383eeb2e | |||
| 84019955ce | |||
| 7da2014ff2 | |||
| df541bd30e | |||
| 905fd28112 | |||
| eee9449e6b | |||
| bdd6b34055 | |||
| 17e206d749 | |||
| 9a49696fca | |||
| 77fed7ae7b | |||
| 9feb450de0 | |||
| a9207988f5 | |||
| 63b199c865 | |||
| 92285c37a2 | |||
| 73e0624964 | |||
| 057db984ed | |||
| 0fa1561d7a | |||
| cffcaa9e78 | |||
| cb9fd1abc0 | |||
| bff3f1c062 | |||
| 6068e78216 | |||
| c80c48ab0a | |||
| be52471cbb | |||
| fc7a6b48e2 | |||
| 75a5fcbee7 | |||
| e61835df17 | |||
| 6062d12f34 | |||
| 3425bd22ba | |||
| 6d2cc5d6cf | |||
| d61dc0b67f | |||
| 20d7aa5523 | |||
| 6d10249a93 | |||
| d10dc679e7 | |||
| 2753309946 | |||
| dc3e3efc66 | |||
| e3eb165f9b | |||
| dec693ca14 | |||
| 3d42a2508c | |||
| 0d93b9b9f4 | |||
| c89612eda5 | |||
| b1e0ce48ae | |||
| 7f04d47ff3 | |||
| b9575ee881 | |||
| dac2c91ae5 | |||
| ef29183160 | |||
| 4ba0dc5cc2 | |||
| daffc8c2ce | |||
| 0ed8d24b54 | |||
| cd7a8c68f9 | |||
| cddad5b790 | |||
| eae809abd1 | |||
| bb964ccd95 | |||
| 2ec51ef113 | |||
| e5682d97a6 | |||
| 5d8087441c | |||
| 211cccfab8 | |||
| e0ac404f96 | |||
| 7cdb6d27e7 | |||
| 60a2e811c0 | |||
| bc0899ba10 | |||
| 8304f35734 | |||
| 2eb0701792 | |||
| 972240f994 | |||
| 017f85243a | |||
| 02666f8762 | |||
| e8ea01980e | |||
| 62a79389ed | |||
| 0614fde503 | |||
| 90d9314c36 | |||
| ed250370ab | |||
| b5580db910 | |||
| a1b138acd2 | |||
| 7b4d2bdd6d | |||
| 7f08212efd | |||
| e66b24b0fc | |||
| febfd409d3 | |||
| 5f3a549851 | |||
| da1246c3cf | |||
| 5b082b5377 | |||
| a1de03d63e | |||
| 4696e428cf | |||
| 0cca80e57d | |||
| eb45c35952 | |||
| b62b8a0f3f | |||
| 4d7737f4d3 | |||
| 500f4ea796 | |||
| 846300df28 | |||
| ec918e0460 | |||
| 885bb41416 | |||
| 3747fa3b8b | |||
| 98f5c818e1 | |||
| 3514d9d3ca | |||
| 8ba113769d | |||
| eef03f5def | |||
| c924bba41e | |||
| 1369fe2451 | |||
| fec97f383d | |||
| ec3c61a877 | |||
| 993be3c0c8 | |||
| 1e5f618ff9 | |||
| efaf22270d | |||
| f9c6da812d | |||
| 286e5f246f | |||
| ef33cd58fd | |||
| e4c44f873a | |||
| a1d8cf5cdf | |||
| 53bb8c7012 | |||
| 6cb1e05784 | |||
| b3eb97adad | |||
| 0b0618e5c8 | |||
| 5c588dd5d3 | |||
| 555d838290 | |||
| d7568a8f16 | |||
| 1bf3bfc082 | |||
| 1e8a9ded70 | |||
| eaf73d6526 | |||
| 150e241601 | |||
| 02928ccadd | |||
| 57615b8c66 | |||
| 66bac035c3 | |||
| b2ed78b317 | |||
| 3d65d42d47 | |||
| 74af1fd5d4 | |||
| 793018e0d6 | |||
| a4865a69b9 | |||
| a07034c13b | |||
| ccc454f8f9 | |||
| 31b348f3cd | |||
| c5e14bd1a4 | |||
| e8a36f7128 | |||
| 4e01805270 | |||
| f0eb151cdd | |||
| 3527556e58 | |||
| a4bfbd3dde | |||
| ff971ce20b | |||
| f82bc56dfa | |||
| 0d0a6d4c91 | |||
| b9105db16c | |||
| ab0f791851 | |||
| 3ddd37242c | |||
| 8a5cb61613 | |||
| 8a77661c14 | |||
| e021119fba | |||
| 237fef8220 | |||
| ee674cfa3f | |||
| 7fc4a5cf66 | |||
| 5f84cdf4b0 | |||
| da9deb1c75 | |||
| 100d729ab6 | |||
| d16da986d8 | |||
| 868769efff | |||
| 97a7a5623b | |||
| 0c44e77ae4 | |||
| 24dadf3668 | |||
| a19c2ee9ef | |||
| 6f7b571dae | |||
| 5753da7dff | |||
| 97f3b48bf8 | |||
| f2e6cd297d | |||
| 011ebec001 | |||
| 2e738e22d2 | |||
| b843047d9a | |||
| ebf1195dc6 | |||
| ab1f92309f | |||
| d3b2be7e86 | |||
| a2131c0d45 | |||
| b179d71658 | |||
| 070ef8f0b0 | |||
| aaeb55b132 | |||
| 1f5cb05f50 | |||
| cee87ed1f5 | |||
| e2ae9c1b95 | |||
| 8b257cdd6c | |||
| f756392b6a | |||
| 894ee88033 | |||
| d5d56e6e23 | |||
| a19a1ec6e8 | |||
| b98015dc76 | |||
| 4112b2af07 | |||
| 944c0d7ed2 | |||
| a471f7059f | |||
| cd1d4244ae | |||
| 573409dcbf | |||
| 6ec70734c1 | |||
| adf6213c9f | |||
| e925672bb6 | |||
| 15c5e257f5 | |||
| 8396964023 | |||
| 09a08011d6 | |||
| 891e0aebb0 | |||
| ca9a7f6051 | |||
| 24dc206462 | |||
| 60e4f924a0 | |||
| 339703ca04 | |||
| 362cba91fb | |||
| a215b82bd9 | |||
| 3393598d91 |
@@ -18,6 +18,13 @@ description: Reviews GitHub pull requests and provides feedback comments. This i
|
||||
4. Ensure any existing review comments have been addressed.
|
||||
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
|
||||
|
||||
## Verification:
|
||||
|
||||
- After the review, run parallel subagents for each finding to double check it.
|
||||
- Spawn up to a maximum of 10 parallel subagents at a time.
|
||||
- Gather the results from the subagents and summarize them in the final review comments.
|
||||
|
||||
|
||||
## IMPORTANT:
|
||||
- Just review. DO NOT make any changes
|
||||
- Be constructive and specific in your comments
|
||||
|
||||
+1
-2
@@ -14,12 +14,11 @@ Dockerfile.dev linguist-language=Dockerfile
|
||||
|
||||
# Generated files
|
||||
CODEOWNERS linguist-generated=true
|
||||
Dockerfile linguist-generated=true
|
||||
homeassistant/generated/*.py linguist-generated=true
|
||||
machine/* linguist-generated=true
|
||||
mypy.ini linguist-generated=true
|
||||
requirements.txt linguist-generated=true
|
||||
requirements_all.txt linguist-generated=true
|
||||
requirements_test_all.txt linguist-generated=true
|
||||
requirements_test_pre_commit.txt linguist-generated=true
|
||||
script/hassfest/docker/Dockerfile linguist-generated=true
|
||||
.github/workflows/*.lock.yml linguist-generated=true
|
||||
|
||||
@@ -16,9 +16,15 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- When opening a pull request, use the repository's PR template (`.github/PULL_REQUEST_TEMPLATE.md`). NEVER REMOVE ANYTHING from the template.
|
||||
- Do not remove checkboxes that are not checked — leave all unchecked checkboxes in place so reviewers can see which options were not selected.
|
||||
|
||||
## Development Commands
|
||||
|
||||
.vscode/tasks.json contains useful commands used for development.
|
||||
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
|
||||
- .vscode/tasks.json contains useful commands used for development.
|
||||
|
||||
## Python Syntax Notes
|
||||
|
||||
@@ -28,10 +34,14 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
## Testing
|
||||
|
||||
- Use `uv run pytest` to run tests
|
||||
- After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `.venv/bin/python3 -m script.translations develop --integration <integration_name>`. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`.
|
||||
- When writing or modifying tests, ensure all test function parameters have type annotations.
|
||||
- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
|
||||
- Prefer `@pytest.mark.usefixtures` over arguments, if the argument is not going to be used.
|
||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
||||
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
@@ -11,3 +11,6 @@ updates:
|
||||
- github_actions
|
||||
cooldown:
|
||||
default-days: 7
|
||||
ignore:
|
||||
# Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump.
|
||||
- dependency-name: "github/gh-aw-actions/**"
|
||||
|
||||
+29
-1
@@ -6,6 +6,7 @@
|
||||
"pep621",
|
||||
"pip_requirements",
|
||||
"pre-commit",
|
||||
"dockerfile",
|
||||
"custom.regex",
|
||||
"homeassistant-manifest"
|
||||
],
|
||||
@@ -21,6 +22,10 @@
|
||||
]
|
||||
},
|
||||
|
||||
"dockerfile": {
|
||||
"managerFilePatterns": ["/^Dockerfile$/"]
|
||||
},
|
||||
|
||||
"homeassistant-manifest": {
|
||||
"managerFilePatterns": [
|
||||
"/^homeassistant/components/[^/]+/manifest\\.json$/"
|
||||
@@ -35,6 +40,14 @@
|
||||
"matchStrings": ["required-version = \">=(?<currentValue>[\\d.]+)\""],
|
||||
"depNameTemplate": "ruff",
|
||||
"datasourceTemplate": "pypi"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Update go2rtc RECOMMENDED_VERSION in const.py alongside the Dockerfile pin",
|
||||
"managerFilePatterns": ["/^homeassistant/components/go2rtc/const\\.py$/"],
|
||||
"matchStrings": ["RECOMMENDED_VERSION = \"(?<currentValue>[\\d.]+)\""],
|
||||
"depNameTemplate": "ghcr.io/alexxit/go2rtc",
|
||||
"datasourceTemplate": "docker"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -115,6 +128,7 @@
|
||||
"standard-aifc",
|
||||
"standard-telnetlib",
|
||||
"ulid-transform",
|
||||
"unidiff",
|
||||
"url-normalize",
|
||||
"xmltodict"
|
||||
],
|
||||
@@ -128,7 +142,8 @@
|
||||
"home-assistant-bluetooth",
|
||||
"home-assistant-frontend",
|
||||
"home-assistant-intents",
|
||||
"infrared-protocols"
|
||||
"infrared-protocols",
|
||||
"rf-protocols"
|
||||
],
|
||||
"enabled": true,
|
||||
"minimumReleaseAge": null,
|
||||
@@ -183,6 +198,13 @@
|
||||
"enabled": true,
|
||||
"labels": ["dependency"]
|
||||
},
|
||||
{
|
||||
"description": "Docker allowlist (ghcr.io exposes no release timestamps so the global cooldown needs to be bypassed)",
|
||||
"matchPackageNames": ["ghcr.io/alexxit/go2rtc"],
|
||||
"enabled": true,
|
||||
"minimumReleaseAge": null,
|
||||
"labels": ["dependency"]
|
||||
},
|
||||
{
|
||||
"description": "Group ruff pre-commit hook with its PyPI twin into one PR",
|
||||
"matchPackageNames": ["astral-sh/ruff-pre-commit", "ruff"],
|
||||
@@ -212,6 +234,12 @@
|
||||
"matchPackageNames": ["pylint", "astroid"],
|
||||
"groupName": "pylint",
|
||||
"groupSlug": "pylint"
|
||||
},
|
||||
{
|
||||
"description": "Group go2rtc Dockerfile pin with const.py RECOMMENDED_VERSION into one PR",
|
||||
"matchPackageNames": ["ghcr.io/alexxit/go2rtc"],
|
||||
"groupName": "go2rtc",
|
||||
"groupSlug": "go2rtc"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ env:
|
||||
UV_HTTP_TIMEOUT: 60
|
||||
UV_SYSTEM_PYTHON: "true"
|
||||
# Base image version from https://github.com/home-assistant/docker
|
||||
BASE_IMAGE_VERSION: "2026.04.0"
|
||||
BASE_IMAGE_VERSION: "2026.05.0"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
permissions: {}
|
||||
@@ -338,7 +338,7 @@ jobs:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Check requirements (deterministic)
|
||||
|
||||
# Stage 1 of the Check requirements pipeline.
|
||||
#
|
||||
# Runs the deterministic Python checks and uploads the structured
|
||||
# results as an artifact. Stage 2 (the agentic workflow defined in
|
||||
# `check-requirements.md`) consumes the artifact on completion.
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
# Auto-trigger on PRs that touch tracked requirement files is disabled
|
||||
# for now while we iterate — testing the workflow_run handoff to the
|
||||
# agentic stage is hard with an auto-trigger. Re-enable once the chain
|
||||
# has been validated end-to-end.
|
||||
# pull_request:
|
||||
# types: [opened, synchronize, reopened]
|
||||
# paths:
|
||||
# - "**/requirements*.txt"
|
||||
# - "homeassistant/package_constraints.txt"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pull_request_number:
|
||||
description: "Pull request number to (re-)check"
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.pull_request_number || github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deterministic:
|
||||
name: Run deterministic requirement checks
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read # To fetch the PR diff via gh CLI
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
- name: Install script dependencies
|
||||
run: pip install -r script/check_requirements/requirements.txt
|
||||
- name: Collect PR diff
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
mkdir -p deterministic
|
||||
gh pr diff "${PR_NUMBER}" > deterministic/pr.diff
|
||||
- name: Run deterministic checks
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
python -m script.check_requirements \
|
||||
--pr-number "${PR_NUMBER}" \
|
||||
--diff deterministic/pr.diff \
|
||||
--output deterministic/results.json
|
||||
- name: Upload deterministic-results artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: check-requirements-deterministic
|
||||
path: deterministic/results.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
+1445
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,378 @@
|
||||
---
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Check requirements (deterministic)"]
|
||||
types: [completed]
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
network:
|
||||
allowed:
|
||||
- python
|
||||
tools:
|
||||
web-fetch: {}
|
||||
github:
|
||||
toolsets: [default, actions]
|
||||
min-integrity: unapproved
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
max: 1
|
||||
target: "${{ needs.extract_pr_number.outputs.pr_number }}"
|
||||
needs:
|
||||
- extract_pr_number
|
||||
jobs:
|
||||
extract_pr_number:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
outputs:
|
||||
pr_number: ${{ steps.extract.outputs.pr_number }}
|
||||
steps:
|
||||
- name: Download deterministic-results artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: check-requirements-deterministic
|
||||
path: /tmp/deterministic
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract PR number from artifact
|
||||
id: extract
|
||||
run: |
|
||||
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
|
||||
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download deterministic-results artifact
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: check-requirements-deterministic
|
||||
path: /tmp/gh-aw/deterministic
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
post-steps:
|
||||
- name: Verify agent produced an add_comment safe-output
|
||||
if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
run: |
|
||||
OUTPUT=/tmp/gh-aw/agent_output.json
|
||||
if [ ! -f "${OUTPUT}" ]; then
|
||||
echo "::error::Agent output file ${OUTPUT} is missing; the agent did not run to completion."
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q '"add_comment"' "${OUTPUT}"; then
|
||||
echo "::error::Agent did not emit an add_comment safe-output; no review comment was posted to the PR."
|
||||
echo "Agent output:"
|
||||
cat "${OUTPUT}"
|
||||
exit 1
|
||||
fi
|
||||
description: >
|
||||
Resolves the deterministic-stage artifact's NEEDS_AGENT checks for changed
|
||||
Python package requirements on PRs targeting the core repo, then posts the
|
||||
final review comment. Triggered by completion of the deterministic workflow.
|
||||
Reads the uploaded artifact from disk, replaces placeholders for any check
|
||||
whose status is `needs_agent`, and posts the merged comment using the PR
|
||||
number recorded inside the artifact itself. Each check kind has a dedicated
|
||||
instruction section below; if the artifact contains a check kind that does
|
||||
not have a section here, the agent fails hard rather than guess.
|
||||
---
|
||||
|
||||
# Check requirements (AW)
|
||||
|
||||
You are a code review assistant for the Home Assistant project. The
|
||||
deterministic stage has already evaluated every check it can on its own
|
||||
and produced an artifact containing the PR number, per-package check
|
||||
results, and a pre-rendered comment with placeholders. **Your only job is
|
||||
to read that artifact, resolve any `needs_agent` checks, and post the
|
||||
final comment.**
|
||||
|
||||
## Step 1 — Read the deterministic-stage artifact
|
||||
|
||||
The deterministic stage uploaded its results to the runner at
|
||||
`/tmp/gh-aw/deterministic/results.json`.
|
||||
|
||||
The JSON has this shape:
|
||||
|
||||
- `pr_number` — the PR being checked. The `add_comment` safe-output is
|
||||
already targeted at this PR (a pre-job extracts `pr_number` from the
|
||||
artifact and the workflow wires it into the safe-output config via
|
||||
`needs.extract_pr_number.outputs.pr_number`), so **you do not need to
|
||||
set `item_number` yourself** — just emit `add_comment` with the
|
||||
rendered body.
|
||||
- `needs_agent` — `true` iff any package's check needs resolution.
|
||||
- `packages[]` — one entry per changed package. Each entry has:
|
||||
- `name`, `old_version` (`null` for a newly added package; otherwise the
|
||||
previous pin), `new_version`, `repo_url`, `publisher_kind`.
|
||||
- `checks` — a dict keyed by **check kind** (string). Each value has a
|
||||
`status` (`pass`, `warn`, `fail`, or `needs_agent`) and `details`.
|
||||
- `rendered_comment` — the final PR comment body, already rendered. For
|
||||
every check whose status is `needs_agent` it contains two placeholders
|
||||
you must replace:
|
||||
- `{{CHECK_CELL:<pkg-name>:<check-kind>}}` — one cell of the summary
|
||||
table. Replace with exactly one of `✅`, `⚠️`, `❌`.
|
||||
- `{{CHECK_DETAIL:<pkg-name>:<check-kind>}}` — the body of one bullet
|
||||
in the package's `<details>` block. Replace with
|
||||
`<icon> <one-line explanation>` (the bullet's leading
|
||||
`- **<label>**:` is already rendered — replace only the placeholder).
|
||||
|
||||
You **must not** modify any other content in `rendered_comment`. Do not
|
||||
re-evaluate checks that already have a deterministic status. Do not add
|
||||
or remove packages.
|
||||
|
||||
## Step 2 — Resolve each `needs_agent` check
|
||||
|
||||
For each `package` in `packages`:
|
||||
|
||||
For each `(check_kind, result)` in `package.checks` where
|
||||
`result.status == "needs_agent"`:
|
||||
|
||||
1. Look up `## Check kind: <check_kind>` in the **Check instructions**
|
||||
section below.
|
||||
2. **If no matching section exists**: emit a single `add_comment` whose
|
||||
body is:
|
||||
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
|
||||
❌ Internal error: the deterministic artifact contains a check kind
|
||||
(`<check_kind>` on package `<pkg-name>`) that this workflow has no
|
||||
instructions for. Update `.github/workflows/check-requirements.md`
|
||||
to add a matching `## Check kind: <check_kind>` section, or remove
|
||||
the kind from the deterministic stage.
|
||||
```
|
||||
|
||||
Then stop. **Do not improvise** a verdict for an unknown check kind.
|
||||
3. Otherwise, follow the instructions in that section. They tell you
|
||||
which icon (✅/⚠️/❌) and one-line explanation to produce.
|
||||
|
||||
## Step 3 — Post the comment
|
||||
|
||||
1. Replace every `{{CHECK_CELL:…}}` and `{{CHECK_DETAIL:…}}` placeholder
|
||||
in `rendered_comment` with the resolved value.
|
||||
2. Emit the resulting markdown using `add_comment` — set `body` to the
|
||||
merged `rendered_comment` verbatim (the leading
|
||||
`<!-- requirements-check -->` marker must be preserved). The PR
|
||||
target is already set by the workflow; do not pass `item_number`.
|
||||
|
||||
If the artifact's top-level `needs_agent` is `false` (no checks need
|
||||
you), emit `rendered_comment` unchanged.
|
||||
|
||||
## Check instructions
|
||||
|
||||
### Check kind: `repo_public`
|
||||
|
||||
Verify that the package's source repository is publicly reachable.
|
||||
|
||||
1. Read `package.repo_url`.
|
||||
2. Use the `web-fetch` tool to GET that URL.
|
||||
3. Decide the verdict:
|
||||
- HTTP 200, returns a public repository page → ✅
|
||||
`<repo_url> is publicly accessible.`
|
||||
- HTTP 4xx/5xx, or the response redirects to a login / sign-in page →
|
||||
❌ `Source repository at <repo_url> is not publicly accessible.
|
||||
Home Assistant requires all dependencies to have publicly available
|
||||
source code.`
|
||||
- Any other inconclusive result → ⚠️ with a one-line description.
|
||||
|
||||
If `repo_public` resolves to ❌ for a package, **also** mark that
|
||||
package's `release_pipeline` and `async_blocking` cells/details as `—`
|
||||
(em dash) and explain `Skipped because the source repository is not
|
||||
publicly accessible.` — neither check can be performed without a public
|
||||
repo.
|
||||
|
||||
### Check kind: `pr_link`
|
||||
|
||||
Verify the PR description contains the right link for the change.
|
||||
|
||||
1. Fetch the PR body via the GitHub MCP tool, using the `pr_number`
|
||||
field from the artifact.
|
||||
2. Extract all URLs from the body.
|
||||
3. For a **new package** (`package.old_version` is `null`):
|
||||
- The PR body must contain a URL that points at `package.repo_url`
|
||||
(any sub-path of the same `owner/repo` on the same host is
|
||||
acceptable). A PyPI link is **not** sufficient.
|
||||
- ✅ if such a URL is present.
|
||||
- ❌ otherwise:
|
||||
`PR description must link to the source repository at <repo_url>.
|
||||
A PyPI page link is not sufficient.`
|
||||
4. For a **version bump** (`package.old_version` is not `null`):
|
||||
- The PR body must contain a URL on the same host as
|
||||
`package.repo_url` that references **both** `package.old_version`
|
||||
and `package.new_version` (e.g. a GitHub compare URL
|
||||
`compare/vX...vY`, a release / changelog URL containing both
|
||||
versions, etc.).
|
||||
- ✅ if such a URL is present and the versions match the actual bump.
|
||||
- ❌ otherwise:
|
||||
`PR description should link to a changelog or compare URL on
|
||||
<repo_url> that mentions both <old_version> and <new_version>.`
|
||||
|
||||
### Check kind: `release_pipeline`
|
||||
|
||||
Inspect the upstream project's release / publish CI pipeline.
|
||||
|
||||
For each package needing inspection, determine the source repository
|
||||
host from `package.repo_url`, then apply the corresponding checklist.
|
||||
|
||||
#### GitHub repositories (`github.com`)
|
||||
|
||||
1. List workflows: `GET /repos/{owner}/{repo}/actions/workflows`.
|
||||
2. Identify any workflow whose name or filename suggests publishing to
|
||||
PyPI (`release`, `publish`, `pypi`, or `deploy`).
|
||||
3. Fetch the workflow file and check:
|
||||
- **Trigger sanity**: triggered by `push` to tags,
|
||||
`release: published`, or `workflow_run` on a release job —
|
||||
**not** solely `workflow_dispatch` with no environment-protection
|
||||
guard.
|
||||
- **OIDC / Trusted Publisher**: look for `id-token: write` and one of
|
||||
`pypa/gh-action-pypi-publish`, `actions/attest-build-provenance`,
|
||||
or `TWINE_PASSWORD` from a static `secrets.PYPI_TOKEN`.
|
||||
- **No manual upload bypass**: no ungated `twine upload` or
|
||||
`pip upload`.
|
||||
4. Verdict:
|
||||
- ✅ if OIDC + sane triggers + no bypass.
|
||||
- ⚠️ if static token but version bump, or details unclear.
|
||||
- ❌ if static token on a new package, or only-manual triggers with
|
||||
no environment protection.
|
||||
|
||||
#### GitLab repositories (`gitlab.com` or self-hosted GitLab)
|
||||
|
||||
1. Resolve the project ID via
|
||||
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`.
|
||||
2. Fetch `.gitlab-ci.yml` via
|
||||
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
|
||||
3. Apply the same conceptual checks: tag-only / protected-branch
|
||||
triggers, GitLab OIDC `id_tokens` or CI/CD protected `PYPI_TOKEN`, no
|
||||
ungated `twine upload`. Same verdict rules as GitHub.
|
||||
|
||||
#### Other code hosting providers (Bitbucket, Codeberg, Gitea, Sourcehut, …)
|
||||
|
||||
1. Use `web-fetch` to retrieve any visible CI configuration
|
||||
(`.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml`,
|
||||
`bitbucket-pipelines.yml`, `.builds/*.yml`).
|
||||
2. Apply the conceptual checks: automated triggers, CI-injected
|
||||
credentials, no manual `twine upload`.
|
||||
3. If no CI config can be retrieved: ⚠️ `Release pipeline could not be
|
||||
inspected; hosting provider is not GitHub or GitLab.`
|
||||
|
||||
### Check kind: `async_blocking`
|
||||
|
||||
Verify whether the dependency performs blocking I/O inside async code
|
||||
paths. Home Assistant runs on a single asyncio event loop, so a library
|
||||
that exposes an `async` surface must not call blocking APIs from inside
|
||||
its `async def` functions — that stalls the whole loop. A purely sync
|
||||
library is fine: Home Assistant integrations are expected to wrap such
|
||||
calls in an executor.
|
||||
|
||||
**Two modes — pick by inspecting `package.old_version`:**
|
||||
|
||||
- `old_version` is `null` → **new package**: review the *entire current
|
||||
source tree*. Nothing about this dependency has been vetted before.
|
||||
- `old_version` is a string → **version bump**: review only the *diff
|
||||
between `old_version` and `new_version`*. The previous version was
|
||||
already accepted, so blocking calls that were present in
|
||||
`old_version` are not regressions; report only what `new_version`
|
||||
introduces.
|
||||
|
||||
#### Step 1 — Decide whether the library exposes an async surface
|
||||
|
||||
Use the `github` MCP tool (for `github.com` repos) or `web-fetch`
|
||||
(other hosts) on `package.repo_url`. Always inspect the tag /
|
||||
ref matching `new_version` (e.g. `v{new_version}` or `{new_version}`).
|
||||
|
||||
- Locate the top-level package directory (usually named after the
|
||||
import name, often equal or close to `package.name`).
|
||||
- Check `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` for
|
||||
async indicators (`Framework :: AsyncIO` trove classifier, `asyncio`
|
||||
/ `aiohttp` / `httpx` / `anyio` in dependencies, an async usage
|
||||
example in the README).
|
||||
- Grep the package source for `async def`. A handful of `async def`
|
||||
entries in the public modules is enough to treat the library as
|
||||
having an async surface.
|
||||
|
||||
If the library is **sync-only** (no `async def` in its public modules
|
||||
and no async framework dependency) → ✅
|
||||
`Sync-only library; Home Assistant integrations must wrap calls in an
|
||||
executor.` *This verdict is the same in both modes.*
|
||||
|
||||
#### Step 2a — Mode: new package (`old_version` is `null`)
|
||||
|
||||
Inspect **every `async def` in the public modules** for blocking
|
||||
patterns. Walk transitively into helpers the async functions call.
|
||||
|
||||
#### Step 2b — Mode: version bump (`old_version` is a string)
|
||||
|
||||
Fetch the diff between the two tags and review **only changed lines**:
|
||||
|
||||
- GitHub: `GET /repos/{owner}/{repo}/compare/{old_tag}...{new_tag}` via
|
||||
the `github` MCP tool, or
|
||||
`https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag}.diff`
|
||||
via `web-fetch`. Try the common tag formats in order until one
|
||||
resolves: `v{version}`, `{version}`, `release-{version}`.
|
||||
- GitLab: `https://gitlab.com/{namespace}/{project}/-/compare/{old_tag}...{new_tag}.diff`.
|
||||
- Other hosts: use the project's equivalent compare URL via
|
||||
`web-fetch`.
|
||||
|
||||
If neither tag format resolves on the host, fall back to a full review
|
||||
(Step 2a) and mention in the detail that the diff was unavailable.
|
||||
|
||||
When reviewing the diff, only flag blocking patterns that appear in
|
||||
**added lines** *inside or reachable from* an `async def`. A blocking
|
||||
call that existed in `old_version` and is unchanged is not a regression
|
||||
for this bump.
|
||||
|
||||
#### Step 3 — Blocking patterns to look for
|
||||
|
||||
In both modes, the patterns to flag inside `async def` bodies are:
|
||||
|
||||
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct use,
|
||||
`http.client.`, sync `httpx.Client(` / `httpx.get(` (NOT the
|
||||
`AsyncClient`), `pycurl`.
|
||||
- `time.sleep(` (must be `await asyncio.sleep(`).
|
||||
- Sync sockets: bare `socket.socket` reads/writes, `ssl.wrap_socket`,
|
||||
blocking `select.select`.
|
||||
- File I/O: `open(` / `pathlib.Path.read_*` / `.write_*` for
|
||||
non-trivial sizes (small one-shot reads during import are
|
||||
acceptable; reads/writes on the request path are not — prefer
|
||||
`aiofiles` / executor).
|
||||
- Sync DB drivers used directly: `sqlite3`, `psycopg2`, `pymysql`,
|
||||
`pymongo` (sync client), `redis.Redis` (sync client).
|
||||
- `subprocess.run` / `subprocess.call` / `os.system` (must be
|
||||
`asyncio.create_subprocess_*`).
|
||||
|
||||
A call that is clearly dispatched to an executor
|
||||
(`run_in_executor`, `asyncio.to_thread`, `anyio.to_thread.run_sync`)
|
||||
does NOT count as blocking.
|
||||
|
||||
#### Step 4 — Verdict
|
||||
|
||||
- ✅ — no offending blocking pattern in the surface being reviewed
|
||||
(whole tree for a new package, added lines for a bump). For a bump,
|
||||
phrase the detail as `No new blocking calls introduced in
|
||||
{old_version} → {new_version}.`.
|
||||
- ⚠️ — blocking calls exist only in sync helpers that the async API
|
||||
does not call, or only on a clearly non-hot path (e.g. one-shot
|
||||
setup before the event loop is running). Cite at least one
|
||||
`<file>:<line>` and explain why it is not on the hot path.
|
||||
- ❌ — a blocking call is reachable from an `async def` that is part
|
||||
of the public API on the request / polling path (for a bump: the
|
||||
call was introduced or moved onto the hot path by this version).
|
||||
Cite the offending `<file>:<line>` as a clickable link on the repo
|
||||
host so the contributor can jump to it.
|
||||
|
||||
## Notes
|
||||
|
||||
- Be constructive and helpful. Reference the inspected workflow / CI
|
||||
file by URL where useful so the contributor can fix the issue.
|
||||
- The dedup of the requirements-check comment is handled by gh-aw's
|
||||
`add_comment` safe-output via the `<!-- requirements-check -->`
|
||||
marker on the first line of `rendered_comment`.
|
||||
- If the deterministic workflow concluded with a non-success status,
|
||||
this workflow's `if:` guard on `Download deterministic-results
|
||||
artifact` skipped the download. If you find no file at
|
||||
`/tmp/gh-aw/deterministic/results.json`, emit nothing — the post-step
|
||||
verification is also gated and will not complain.
|
||||
@@ -632,7 +632,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
@@ -1088,6 +1088,7 @@ jobs:
|
||||
options: >-
|
||||
--health-cmd="if command -v mariadb-admin >/dev/null; then mariadb-admin ping -uroot -ppassword; else mysqladmin ping -uroot -ppassword; fi"
|
||||
--health-interval=5s --health-timeout=2s --health-retries=3
|
||||
--tmpfs /var/lib/mysql:size=2g,mode=0750
|
||||
needs:
|
||||
- info
|
||||
- base
|
||||
@@ -1245,7 +1246,10 @@ jobs:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_PASSWORD: password
|
||||
options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
options: >-
|
||||
--health-cmd="pg_isready -hlocalhost -Upostgres"
|
||||
--health-interval=5s --health-timeout=2s --health-retries=3
|
||||
--tmpfs /var/lib/postgresql/data:size=2g,mode=0700
|
||||
needs:
|
||||
- info
|
||||
- base
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.12
|
||||
rev: v0.15.13
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
@@ -23,6 +23,7 @@ repos:
|
||||
- id: zizmor
|
||||
args:
|
||||
- --pedantic
|
||||
exclude: ^\.github/workflows/.*\.lock\.yml$
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
@@ -46,6 +47,7 @@ repos:
|
||||
additional_dependencies:
|
||||
- prettier@3.6.2
|
||||
- prettier-plugin-sort-json@4.2.0
|
||||
exclude: ^\.github/workflows/.*\.lock\.yml$
|
||||
- repo: https://github.com/cdce8p/python-typing-update
|
||||
rev: v0.6.0
|
||||
hooks:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.14.4
|
||||
3.14.5
|
||||
|
||||
@@ -250,6 +250,7 @@ homeassistant.components.gpsd.*
|
||||
homeassistant.components.greeneye_monitor.*
|
||||
homeassistant.components.group.*
|
||||
homeassistant.components.guardian.*
|
||||
homeassistant.components.guntamatic.*
|
||||
homeassistant.components.habitica.*
|
||||
homeassistant.components.hardkernel.*
|
||||
homeassistant.components.hardware.*
|
||||
@@ -357,6 +358,7 @@ homeassistant.components.lunatone.*
|
||||
homeassistant.components.lutron.*
|
||||
homeassistant.components.madvr.*
|
||||
homeassistant.components.manual.*
|
||||
homeassistant.components.marantz_infrared.*
|
||||
homeassistant.components.mastodon.*
|
||||
homeassistant.components.matrix.*
|
||||
homeassistant.components.matter.*
|
||||
@@ -423,6 +425,7 @@ homeassistant.components.opower.*
|
||||
homeassistant.components.oralb.*
|
||||
homeassistant.components.otbr.*
|
||||
homeassistant.components.otp.*
|
||||
homeassistant.components.ouman_eh_800.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.overseerr.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
@@ -489,6 +492,7 @@ homeassistant.components.rss_feed_template.*
|
||||
homeassistant.components.russound_rio.*
|
||||
homeassistant.components.ruuvi_gateway.*
|
||||
homeassistant.components.ruuvitag_ble.*
|
||||
homeassistant.components.samsung_infrared.*
|
||||
homeassistant.components.samsungtv.*
|
||||
homeassistant.components.saunum.*
|
||||
homeassistant.components.scene.*
|
||||
|
||||
Vendored
+3
-3
@@ -132,7 +132,7 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install all Requirements",
|
||||
"label": "Install all production Requirements",
|
||||
"type": "shell",
|
||||
"command": "uv pip install -r requirements_all.txt",
|
||||
"group": {
|
||||
@@ -146,9 +146,9 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install all Test Requirements",
|
||||
"label": "Install all (test & production) Requirements",
|
||||
"type": "shell",
|
||||
"command": "uv pip install -r requirements.txt -r requirements_test_all.txt",
|
||||
"command": "uv pip install -r requirements_all.txt -r requirements_test.txt",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
ignore: |
|
||||
tests/fixtures/core/config/yaml_errors/
|
||||
.github/workflows/*.lock.yml
|
||||
rules:
|
||||
braces:
|
||||
level: error
|
||||
|
||||
@@ -6,9 +6,15 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- When opening a pull request, use the repository's PR template (`.github/PULL_REQUEST_TEMPLATE.md`). NEVER REMOVE ANYTHING from the template.
|
||||
- Do not remove checkboxes that are not checked — leave all unchecked checkboxes in place so reviewers can see which options were not selected.
|
||||
|
||||
## Development Commands
|
||||
|
||||
.vscode/tasks.json contains useful commands used for development.
|
||||
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
|
||||
- .vscode/tasks.json contains useful commands used for development.
|
||||
|
||||
## Python Syntax Notes
|
||||
|
||||
@@ -18,10 +24,14 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
## Testing
|
||||
|
||||
- Use `uv run pytest` to run tests
|
||||
- After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `.venv/bin/python3 -m script.translations develop --integration <integration_name>`. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`.
|
||||
- When writing or modifying tests, ensure all test function parameters have type annotations.
|
||||
- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
|
||||
- Prefer `@pytest.mark.usefixtures` over arguments, if the argument is not going to be used.
|
||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
||||
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
Generated
+20
-8
@@ -68,6 +68,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/agent_dvr/ @ispysoftware
|
||||
/homeassistant/components/ai_task/ @home-assistant/core
|
||||
/tests/components/ai_task/ @home-assistant/core
|
||||
/homeassistant/components/aidot/ @s1eedz @HongBryan
|
||||
/tests/components/aidot/ @s1eedz @HongBryan
|
||||
/homeassistant/components/air_quality/ @home-assistant/core
|
||||
/tests/components/air_quality/ @home-assistant/core
|
||||
/homeassistant/components/airgradient/ @airgradienthq @joostlek
|
||||
@@ -695,6 +697,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/growatt_server/ @johanzander
|
||||
/homeassistant/components/guardian/ @bachya
|
||||
/tests/components/guardian/ @bachya
|
||||
/homeassistant/components/guntamatic/ @JensTimmerman
|
||||
/tests/components/guntamatic/ @JensTimmerman
|
||||
/homeassistant/components/habitica/ @tr4nt0r
|
||||
/tests/components/habitica/ @tr4nt0r
|
||||
/homeassistant/components/hanna/ @bestycame
|
||||
@@ -979,8 +983,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/lektrico/ @lektrico
|
||||
/homeassistant/components/letpot/ @jpelgrom
|
||||
/tests/components/letpot/ @jpelgrom
|
||||
/homeassistant/components/lg_infrared/ @home-assistant/core
|
||||
/tests/components/lg_infrared/ @home-assistant/core
|
||||
/homeassistant/components/lg_infrared/ @abmantis
|
||||
/tests/components/lg_infrared/ @abmantis
|
||||
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
|
||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
@@ -1043,6 +1047,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/lyric/ @timmo001
|
||||
/homeassistant/components/madvr/ @iloveicedgreentea
|
||||
/tests/components/madvr/ @iloveicedgreentea
|
||||
/homeassistant/components/marantz_infrared/ @balloob
|
||||
/tests/components/marantz_infrared/ @balloob
|
||||
/homeassistant/components/mastodon/ @fabaff @andrew-codechimp
|
||||
/tests/components/mastodon/ @fabaff @andrew-codechimp
|
||||
/homeassistant/components/matrix/ @PaarthShah
|
||||
@@ -1305,6 +1311,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/osoenergy/ @osohotwateriot
|
||||
/homeassistant/components/otbr/ @home-assistant/core
|
||||
/tests/components/otbr/ @home-assistant/core
|
||||
/homeassistant/components/ouman_eh_800/ @Markus98
|
||||
/tests/components/ouman_eh_800/ @Markus98
|
||||
/homeassistant/components/ourgroceries/ @OnFreund
|
||||
/tests/components/ourgroceries/ @OnFreund
|
||||
/homeassistant/components/overkiz/ @imicknl
|
||||
@@ -1405,8 +1413,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/pushover/ @engrbm87
|
||||
/homeassistant/components/pvoutput/ @frenck
|
||||
/tests/components/pvoutput/ @frenck
|
||||
/homeassistant/components/pvpc_hourly_pricing/ @azogue
|
||||
/tests/components/pvpc_hourly_pricing/ @azogue
|
||||
/homeassistant/components/pvpc_hourly_pricing/ @azogue @chiro79
|
||||
/tests/components/pvpc_hourly_pricing/ @azogue @chiro79
|
||||
/homeassistant/components/pyload/ @tr4nt0r
|
||||
/tests/components/pyload/ @tr4nt0r
|
||||
/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39
|
||||
@@ -1528,8 +1536,10 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/sabnzbd/ @shaiu @jpbede
|
||||
/tests/components/sabnzbd/ @shaiu @jpbede
|
||||
/homeassistant/components/saj/ @fredericvl
|
||||
/homeassistant/components/samsungtv/ @chemelli74 @epenet
|
||||
/tests/components/samsungtv/ @chemelli74 @epenet
|
||||
/homeassistant/components/samsung_infrared/ @lmaertin
|
||||
/tests/components/samsung_infrared/ @lmaertin
|
||||
/homeassistant/components/samsungtv/ @chemelli74
|
||||
/tests/components/samsungtv/ @chemelli74
|
||||
/homeassistant/components/sanix/ @tomaszsluszniak
|
||||
/tests/components/sanix/ @tomaszsluszniak
|
||||
/homeassistant/components/satel_integra/ @Tommatheussen
|
||||
@@ -2026,6 +2036,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG
|
||||
/homeassistant/components/xiaomi_tv/ @simse
|
||||
/homeassistant/components/xmpp/ @fabaff @flowolf
|
||||
/homeassistant/components/xthings_cloud/ @XthingsJacobs
|
||||
/tests/components/xthings_cloud/ @XthingsJacobs
|
||||
/homeassistant/components/yale/ @bdraco
|
||||
/tests/components/yale/ @bdraco
|
||||
/homeassistant/components/yale_smart_alarm/ @gjohansson-ST
|
||||
@@ -2056,8 +2068,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/zeroconf/ @bdraco
|
||||
/homeassistant/components/zerproc/ @emlove
|
||||
/tests/components/zerproc/ @emlove
|
||||
/homeassistant/components/zeversolar/ @kvanzuijlen
|
||||
/tests/components/zeversolar/ @kvanzuijlen
|
||||
/homeassistant/components/zeversolar/ @kvanzuijlen @mhuiskes
|
||||
/tests/components/zeversolar/ @kvanzuijlen @mhuiskes
|
||||
/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
|
||||
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
|
||||
/homeassistant/components/zimi/ @markhannon
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
|
||||
# Automatically generated by hassfest.
|
||||
# Partly generated by hassfest.
|
||||
#
|
||||
# To update, run python3 -m script.hassfest -p docker
|
||||
ARG BUILD_FROM
|
||||
@@ -26,7 +26,7 @@ WORKDIR /usr/src
|
||||
COPY rootfs /
|
||||
|
||||
# Add go2rtc binary
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
|
||||
COPY --from=ghcr.io/alexxit/go2rtc:1.9.14@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
|
||||
|
||||
## Setup Home Assistant Core dependencies
|
||||
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
|
||||
|
||||
@@ -73,10 +73,12 @@ async def auth_manager_from_config(
|
||||
provider_hash[key] = provider
|
||||
|
||||
if isinstance(provider, HassAuthProvider):
|
||||
# Can be removed in 2026.7 with the legacy mode of homeassistant auth provider
|
||||
# We need to initialize the provider to create the repair if needed as otherwise
|
||||
# the provider will be initialized on first use, which could be rare as users
|
||||
# don't frequently change auth settings
|
||||
# Can be removed in 2026.7 with the legacy mode of
|
||||
# homeassistant auth provider.
|
||||
# We need to initialize the provider to create the repair
|
||||
# if needed as otherwise the provider will be initialized
|
||||
# on first use, which could be rare as users don't
|
||||
# frequently change auth settings
|
||||
await provider.async_initialize()
|
||||
|
||||
if module_configs:
|
||||
|
||||
@@ -120,9 +120,10 @@ class Data:
|
||||
if self.normalize_username(username, force_normalize=True) != username:
|
||||
logging.getLogger(__name__).warning(
|
||||
(
|
||||
"Home Assistant auth provider is running in legacy mode "
|
||||
"because we detected usernames that are normalized (lowercase and without spaces)."
|
||||
" Please change the username: '%s'."
|
||||
"Home Assistant auth provider is running in"
|
||||
" legacy mode because we detected usernames"
|
||||
" that are normalized (lowercase and without"
|
||||
" spaces). Please change the username: '%s'."
|
||||
),
|
||||
username,
|
||||
)
|
||||
@@ -139,7 +140,9 @@ class Data:
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="homeassistant_provider_not_normalized_usernames",
|
||||
translation_placeholders={
|
||||
"usernames": f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
|
||||
"usernames": (
|
||||
f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
|
||||
)
|
||||
},
|
||||
learn_more_url="homeassistant://config/users",
|
||||
)
|
||||
|
||||
@@ -60,7 +60,10 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
|
||||
|
||||
|
||||
def _clear_configuration_directory(config_dir: Path, keep: Iterable[str]) -> None:
|
||||
"""Delete all files and directories in the config directory except entries in the keep list."""
|
||||
"""Delete all files and directories in the config dir.
|
||||
|
||||
Entries in the keep list are preserved.
|
||||
"""
|
||||
keep_paths = [config_dir.joinpath(path) for path in keep]
|
||||
entries_to_remove = sorted(
|
||||
entry for entry in config_dir.iterdir() if entry not in keep_paths
|
||||
@@ -101,7 +104,8 @@ def _extract_backup(
|
||||
)
|
||||
) > HA_VERSION:
|
||||
raise ValueError(
|
||||
f"You need at least Home Assistant version {backup_meta_version} to restore this backup"
|
||||
f"You need at least Home Assistant version"
|
||||
f" {backup_meta_version} to restore this backup"
|
||||
)
|
||||
|
||||
with securetar.SecureTarFile(
|
||||
|
||||
@@ -17,7 +17,8 @@ from time import monotonic
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
# Import cryptography early since import openssl is not thread-safe
|
||||
# _frozen_importlib._DeadlockError: deadlock detected by _ModuleLock('cryptography.hazmat.backends.openssl.backend')
|
||||
# _frozen_importlib._DeadlockError: deadlock detected by
|
||||
# _ModuleLock('cryptography.hazmat.backends.openssl.backend')
|
||||
import cryptography.hazmat.backends.openssl.backend # noqa: F401
|
||||
import voluptuous as vol
|
||||
import yarl
|
||||
@@ -165,10 +166,14 @@ FRONTEND_INTEGRATIONS = {
|
||||
# visible in frontend
|
||||
"frontend",
|
||||
}
|
||||
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
|
||||
# The substage containing recorder should have no timeout, as it could cancel a database migration.
|
||||
# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
|
||||
# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
|
||||
# Stage 0 is divided into substages. Each substage has a name,
|
||||
# a set of integrations and a timeout.
|
||||
# The substage containing recorder should have no timeout, as it
|
||||
# could cancel a database migration.
|
||||
# Recorder freezes "recorder" timeout during a migration, but it
|
||||
# does not freeze other timeouts.
|
||||
# If we add timeouts to the frontend substages, we should make sure
|
||||
# they don't apply in recovery mode.
|
||||
STAGE_0_INTEGRATIONS = (
|
||||
# Load logging and http deps as soon as possible
|
||||
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "marantz",
|
||||
"name": "Marantz",
|
||||
"integrations": ["marantz", "marantz_infrared"]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "samsung",
|
||||
"name": "Samsung",
|
||||
"integrations": ["familyhub", "samsungtv", "syncthru"]
|
||||
"integrations": ["familyhub", "samsung_infrared", "samsungtv", "syncthru"]
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ def _change_setting(call: ServiceCall) -> None:
|
||||
|
||||
try:
|
||||
_get_abode_system(call.hass).abode.set_setting(setting, value)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except AbodeException as ex:
|
||||
LOGGER.warning(ex)
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["serialx==1.7.3"]
|
||||
"requirements": ["serialx==1.8.0"]
|
||||
}
|
||||
|
||||
@@ -79,6 +79,12 @@
|
||||
"exceptions": {
|
||||
"config_entry_not_loaded": {
|
||||
"message": "Config entry not loaded."
|
||||
},
|
||||
"error_while_turn_off": {
|
||||
"message": "An error occurred while turning off AdGuard Home switch."
|
||||
},
|
||||
"error_while_turn_on": {
|
||||
"message": "An error occurred while turning on AdGuard Home switch."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -9,10 +9,11 @@ from adguardhome import AdGuardHome, AdGuardHomeError
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdGuardConfigEntry, AdGuardData
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import DOMAIN
|
||||
from .entity import AdGuardHomeEntity
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
@@ -116,17 +117,23 @@ class AdGuardHomeSwitch(AdGuardHomeEntity, SwitchEntity):
|
||||
"""Turn off the switch."""
|
||||
try:
|
||||
await self.entity_description.turn_off_fn(self.adguard)()
|
||||
except AdGuardHomeError:
|
||||
LOGGER.error("An error occurred while turning off AdGuard Home switch")
|
||||
except AdGuardHomeError as err:
|
||||
self._attr_available = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_while_turn_off",
|
||||
) from err
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
try:
|
||||
await self.entity_description.turn_on_fn(self.adguard)()
|
||||
except AdGuardHomeError:
|
||||
LOGGER.error("An error occurred while turning on AdGuard Home switch")
|
||||
except AdGuardHomeError as err:
|
||||
self._attr_available = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_while_turn_on",
|
||||
) from err
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
|
||||
@@ -106,7 +106,8 @@ class AdsLight(AdsEntity, LightEntity):
|
||||
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
|
||||
self._attr_color_mode = next(iter(self._attr_supported_color_modes))
|
||||
|
||||
# Set color temperature range (static config values take precedence over defaults)
|
||||
# Set color temperature range
|
||||
# (static config values take precedence over defaults)
|
||||
if ads_var_color_temp_kelvin is not None:
|
||||
self._attr_min_color_temp_kelvin = (
|
||||
min_color_temp_kelvin
|
||||
|
||||
@@ -19,6 +19,7 @@ from .hub import AdsHub
|
||||
|
||||
DEFAULT_NAME = "ADS select"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_OPTIONS = "options"
|
||||
|
||||
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
|
||||
|
||||
@@ -171,7 +171,8 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the current target temperature."""
|
||||
# If the system is in MyZone mode, and a zone is set, return that temperature instead.
|
||||
# If the system is in MyZone mode, and a zone is set,
|
||||
# return that temperature instead.
|
||||
if self._myzone and self.preset_mode == ADVANTAGE_AIR_MYZONE:
|
||||
return self._myzone["setTemp"]
|
||||
return self._ac["setTemp"]
|
||||
@@ -296,7 +297,11 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the HVAC action, inheriting from master AC if zone is open but idle if air is <= 5%."""
|
||||
"""Return the HVAC action.
|
||||
|
||||
Inherits from master AC if zone is open but idle if air
|
||||
is <= 5%.
|
||||
"""
|
||||
if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF:
|
||||
return HVACAction.OFF
|
||||
master_action = HVAC_ACTIONS.get(self._ac["mode"], HVACAction.OFF)
|
||||
|
||||
@@ -59,6 +59,8 @@ class AemetConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=home-assistant-config-flow-name-field
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
|
||||
vol.Optional(
|
||||
CONF_LATITUDE, default=self.hass.config.latitude
|
||||
|
||||
@@ -56,6 +56,7 @@ async def async_setup_entry(
|
||||
)
|
||||
async_dispatcher_send(hass, UPDATE_TOPIC)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_TRACKING,
|
||||
@@ -71,6 +72,7 @@ async def async_setup_entry(
|
||||
)
|
||||
async_dispatcher_send(hass, UPDATE_TOPIC)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_REMOVE_TRACKING,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""The aidot integration."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AidotConfigEntry, AidotDeviceManagerCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool:
|
||||
"""Set up aidot from a config entry."""
|
||||
|
||||
coordinator = AidotDeviceManagerCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.async_cleanup()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Config flow for Aidot integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aidot.client import AidotClient
|
||||
from aidot.const import CONF_ID, DEFAULT_COUNTRY_CODE, SUPPORTED_COUNTRY_CODES
|
||||
from aidot.exceptions import AidotUserOrPassIncorrect
|
||||
from aiohttp import ClientError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_COUNTRY_CODE,
|
||||
default=DEFAULT_COUNTRY_CODE,
|
||||
): selector.CountrySelector(
|
||||
selector.CountrySelectorConfig(
|
||||
countries=SUPPORTED_COUNTRY_CODES,
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AidotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle aidot config flow."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
client = AidotClient(
|
||||
session=async_get_clientsession(self.hass),
|
||||
country_code=user_input[CONF_COUNTRY_CODE],
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
try:
|
||||
login_info = await client.async_post_login()
|
||||
except AidotUserOrPassIncorrect:
|
||||
errors["base"] = "invalid_auth"
|
||||
except TimeoutError, ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if not errors:
|
||||
await self.async_set_unique_id(login_info[CONF_ID])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=f"{user_input[CONF_USERNAME]} {user_input[CONF_COUNTRY_CODE]}",
|
||||
data=login_info,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Constants for the aidot integration."""
|
||||
|
||||
DOMAIN = "aidot"
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Coordinator for Aidot."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aidot.client import AidotClient
|
||||
from aidot.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_AES_KEY,
|
||||
CONF_DEVICE_LIST,
|
||||
CONF_ID,
|
||||
CONF_TYPE,
|
||||
)
|
||||
from aidot.device_client import DeviceClient, DeviceStatusData
|
||||
from aidot.exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
type AidotConfigEntry = ConfigEntry[AidotDeviceManagerCoordinator]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_DEVICE_LIST_INTERVAL = timedelta(hours=6)
|
||||
|
||||
|
||||
class AidotDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceStatusData]):
|
||||
"""Class to manage Aidot data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: AidotConfigEntry,
|
||||
device_client: DeviceClient,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=None,
|
||||
)
|
||||
self.device_client = device_client
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
self.device_client.on_status_update = self._handle_status_update
|
||||
|
||||
def _handle_status_update(self, status: DeviceStatusData) -> None:
|
||||
"""Handle status callback."""
|
||||
self.async_set_updated_data(status)
|
||||
|
||||
async def _async_update_data(self) -> DeviceStatusData:
|
||||
"""Return current status."""
|
||||
return self.device_client.status
|
||||
|
||||
|
||||
class AidotDeviceManagerCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Class to manage fetching Aidot data."""
|
||||
|
||||
config_entry: AidotConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: AidotConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_DEVICE_LIST_INTERVAL,
|
||||
)
|
||||
self.client = AidotClient(
|
||||
session=async_get_clientsession(hass),
|
||||
token=config_entry.data,
|
||||
)
|
||||
self.client.set_token_fresh_cb(self.token_fresh_cb)
|
||||
self.device_coordinators: dict[str, AidotDeviceUpdateCoordinator] = {}
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
try:
|
||||
await self.async_auto_login()
|
||||
except AidotUserOrPassIncorrect as error:
|
||||
raise ConfigEntryError from error
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update data async."""
|
||||
try:
|
||||
data = await self.client.async_get_all_device()
|
||||
except AidotAuthFailed as error:
|
||||
raise ConfigEntryError from error
|
||||
current_devices = {
|
||||
device[CONF_ID]: device
|
||||
for device in data[CONF_DEVICE_LIST]
|
||||
if (
|
||||
device[CONF_TYPE] == "light"
|
||||
and CONF_AES_KEY in device
|
||||
and device[CONF_AES_KEY][0] is not None
|
||||
)
|
||||
}
|
||||
|
||||
removed_ids = set(self.device_coordinators) - set(current_devices)
|
||||
for dev_id in removed_ids:
|
||||
coordinator = self.device_coordinators.pop(dev_id)
|
||||
coordinator.device_client.on_status_update = None
|
||||
if removed_ids:
|
||||
self._purge_deleted_lists()
|
||||
|
||||
for dev_id, device in current_devices.items():
|
||||
if dev_id not in self.device_coordinators:
|
||||
device_client = self.client.get_device_client(device)
|
||||
device_coordinator = AidotDeviceUpdateCoordinator(
|
||||
self.hass, self.config_entry, device_client
|
||||
)
|
||||
await device_coordinator.async_config_entry_first_refresh()
|
||||
self.device_coordinators[dev_id] = device_coordinator
|
||||
|
||||
async def async_cleanup(self) -> None:
|
||||
"""Perform cleanup actions."""
|
||||
for coordinator in self.device_coordinators.values():
|
||||
coordinator.device_client.on_status_update = None
|
||||
await self.client.async_cleanup()
|
||||
|
||||
def token_fresh_cb(self) -> None:
|
||||
"""Update token."""
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data=self.client.login_info.copy()
|
||||
)
|
||||
|
||||
async def async_auto_login(self) -> None:
|
||||
"""Async auto login."""
|
||||
if self.client.login_info.get(CONF_ACCESS_TOKEN) is None:
|
||||
await self.client.async_post_login()
|
||||
|
||||
def _purge_deleted_lists(self) -> None:
|
||||
"""Purge device entries of deleted lists."""
|
||||
|
||||
device_reg = dr.async_get(self.hass)
|
||||
identifiers = {
|
||||
(
|
||||
DOMAIN,
|
||||
device_coordinator.device_client.info.dev_id,
|
||||
)
|
||||
for device_coordinator in self.device_coordinators.values()
|
||||
}
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_reg, self.config_entry.entry_id
|
||||
):
|
||||
if not set(device.identifiers) & identifiers:
|
||||
_LOGGER.debug("Removing obsolete device entry %s", device.name)
|
||||
device_reg.async_update_device(
|
||||
device.id, remove_config_entry_id=self.config_entry.entry_id
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Support for Aidot lights."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGBW_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AidotConfigEntry, AidotDeviceUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AidotConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Light."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
AidotLight(device_coordinator)
|
||||
for device_coordinator in coordinator.device_coordinators.values()
|
||||
)
|
||||
|
||||
|
||||
class AidotLight(CoordinatorEntity[AidotDeviceUpdateCoordinator], LightEntity):
|
||||
"""Representation of a Aidot Wi-Fi Light."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, coordinator: AidotDeviceUpdateCoordinator) -> None:
|
||||
"""Initialize the light."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = coordinator.device_client.info.dev_id
|
||||
if hasattr(coordinator.device_client.info, "cct_max"):
|
||||
self._attr_max_color_temp_kelvin = coordinator.device_client.info.cct_max
|
||||
if hasattr(coordinator.device_client.info, "cct_min"):
|
||||
self._attr_min_color_temp_kelvin = coordinator.device_client.info.cct_min
|
||||
|
||||
model_id = coordinator.device_client.info.model_id
|
||||
manufacturer = model_id.split(".")[0]
|
||||
model = model_id[len(manufacturer) + 1 :]
|
||||
mac = coordinator.device_client.info.mac
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)},
|
||||
manufacturer=manufacturer,
|
||||
model=model,
|
||||
name=coordinator.device_client.info.name,
|
||||
hw_version=coordinator.device_client.info.hw_version,
|
||||
)
|
||||
if coordinator.device_client.info.enable_rgbw:
|
||||
self._attr_color_mode = ColorMode.RGBW
|
||||
self._attr_supported_color_modes = {ColorMode.RGBW, ColorMode.COLOR_TEMP}
|
||||
elif coordinator.device_client.info.enable_cct:
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP}
|
||||
else:
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
self._update_status()
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update light status from coordinator data."""
|
||||
self._attr_is_on = self.coordinator.data.on
|
||||
self._attr_brightness = self.coordinator.data.dimming
|
||||
self._attr_color_temp_kelvin = self.coordinator.data.cct
|
||||
self._attr_rgbw_color = self.coordinator.data.rgbw
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.coordinator.data.online
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Update."""
|
||||
self._update_status()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on, applying brightness, color temperature, RGBW, or plain on."""
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
|
||||
await self.coordinator.device_client.async_set_brightness(brightness)
|
||||
self.coordinator.data.dimming = brightness
|
||||
self._attr_brightness = brightness
|
||||
elif ATTR_COLOR_TEMP_KELVIN in kwargs:
|
||||
color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
await self.coordinator.device_client.async_set_cct(color_temp_kelvin)
|
||||
self.coordinator.data.cct = color_temp_kelvin
|
||||
self._attr_color_temp_kelvin = color_temp_kelvin
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
elif ATTR_RGBW_COLOR in kwargs:
|
||||
rgbw_color = kwargs.get(ATTR_RGBW_COLOR)
|
||||
await self.coordinator.device_client.async_set_rgbw(rgbw_color)
|
||||
self.coordinator.data.rgbw = rgbw_color
|
||||
self._attr_rgbw_color = rgbw_color
|
||||
self._attr_color_mode = ColorMode.RGBW
|
||||
else:
|
||||
await self.coordinator.device_client.async_turn_on()
|
||||
|
||||
self.coordinator.data.on = True
|
||||
self._attr_is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self.coordinator.device_client.async_turn_off()
|
||||
self.coordinator.data.on = False
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "aidot",
|
||||
"name": "AiDot",
|
||||
"codeowners": ["@s1eedz", "@HongBryan"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/aidot",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-aidot==0.3.53"]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: This integration does not register any events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: This integration has no option flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
entity-disabled-by-default: todo
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"country_code": "Country",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"country_code": "The country selected by AiDot app when logging in",
|
||||
"password": "Password for logging in through AiDot app",
|
||||
"username": "Account logged in through AiDot app"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,20 +68,24 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
),
|
||||
"co_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
"co_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"ozone_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
),
|
||||
"ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
"ozone_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"voc_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{
|
||||
@@ -92,14 +96,16 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
MassVolumeConcentrationConverter,
|
||||
),
|
||||
"voc_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
MassVolumeConcentrationConverter,
|
||||
"voc_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
MassVolumeConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"voc_ratio_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{
|
||||
@@ -110,44 +116,60 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitlessRatioConverter,
|
||||
),
|
||||
"voc_ratio_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitlessRatioConverter,
|
||||
"voc_ratio_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitlessRatioConverter,
|
||||
)
|
||||
),
|
||||
"no_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
),
|
||||
"no_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
"no_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.NITROGEN_MONOXIDE
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"no2_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
),
|
||||
"no2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
"no2_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.NITROGEN_DIOXIDE
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"so2_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
),
|
||||
"so2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
"so2_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
)
|
||||
),
|
||||
# Numerical sensor triggers without unit conversion (single-unit device classes)
|
||||
"co2_changed": make_entity_numerical_state_changed_trigger(
|
||||
|
||||
@@ -184,7 +184,8 @@ async def async_setup_entry(
|
||||
(
|
||||
AirlySensor(coordinator, name, description)
|
||||
for description in SENSOR_TYPES
|
||||
# When we use the nearest method, we are not sure which sensors are available
|
||||
# When we use the nearest method, we are not sure
|
||||
# which sensors are available
|
||||
if coordinator.data.get(description.key)
|
||||
),
|
||||
False,
|
||||
|
||||
@@ -75,7 +75,9 @@ def aqi_extra_attrs(data: dict[str, Any]) -> dict[str, Any]:
|
||||
ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION],
|
||||
ATTR_LEVEL: data[ATTR_API_AQI_LEVEL],
|
||||
ATTR_TIME: parser.parse(
|
||||
f"{data[ATTR_API_REPORT_DATE]} {data[ATTR_API_REPORT_HOUR]}:00 {data[ATTR_API_REPORT_TZ]}",
|
||||
f"{data[ATTR_API_REPORT_DATE]} "
|
||||
f"{data[ATTR_API_REPORT_HOUR]}:00 "
|
||||
f"{data[ATTR_API_REPORT_TZ]}",
|
||||
tzinfos=US_TZ_OFFSETS,
|
||||
).isoformat(),
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ class AirobotButton(AirobotEntity, ButtonEntity):
|
||||
"""Handle the button press."""
|
||||
try:
|
||||
await self.entity_description.press_fn(self.coordinator)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except AirobotConnectionError, AirobotTimeoutError:
|
||||
# Connection errors during reboot are expected as device restarts
|
||||
pass
|
||||
|
||||
@@ -81,8 +81,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
|
||||
) as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except AirOSKeyDataMissingError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryError("key_data_missing") from err
|
||||
except Exception as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryError("unknown") from err
|
||||
|
||||
airos_class: type[AirOS8 | AirOS6] = (
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from airos.data import AirOSDataBaseClass
|
||||
|
||||
@@ -20,13 +19,10 @@ from .entity import AirOSEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirOSBinarySensorEntityDescription(
|
||||
class AirOSBinarySensorEntityDescription[AirOSDataModel: AirOSDataBaseClass](
|
||||
BinarySensorEntityDescription,
|
||||
Generic[AirOSDataModel],
|
||||
):
|
||||
"""Describe an AirOS binary sensor."""
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from airos.data import (
|
||||
AirOSDataBaseClass,
|
||||
@@ -41,11 +40,11 @@ WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirOSSensorEntityDescription(SensorEntityDescription, Generic[AirOSDataModel]):
|
||||
class AirOSSensorEntityDescription[AirOSDataModel: AirOSDataBaseClass](
|
||||
SensorEntityDescription
|
||||
):
|
||||
"""Describe an AirOS sensor."""
|
||||
|
||||
value_fn: Callable[[AirOSDataModel], StateType]
|
||||
|
||||
@@ -54,7 +54,8 @@ class AirQCoordinator(DataUpdateCoordinator):
|
||||
"""Fetch the data from the device."""
|
||||
if "name" not in self.device_info:
|
||||
_LOGGER.debug(
|
||||
"'name' not found in AirQCoordinator.device_info, fetching from the device"
|
||||
"'name' not found in AirQCoordinator.device_info,"
|
||||
" fetching from the device"
|
||||
)
|
||||
info = await self.airq.fetch_device_info()
|
||||
self.device_info.update(
|
||||
|
||||
@@ -158,7 +158,8 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
|
||||
await self._airtouch.SetCoolingModeForAc(
|
||||
self._ac_number, HA_STATE_TO_AT[hvac_mode]
|
||||
)
|
||||
# in case it isn't already, unless the HVAC mode was off, then the ac should be on
|
||||
# in case it isn't already, unless the HVAC mode was off,
|
||||
# then the ac should be on
|
||||
await self.async_turn_on()
|
||||
self._unit = self._airtouch.GetAcs()[self._ac_number]
|
||||
_LOGGER.debug("Setting operation mode of %s to %s", self._ac_number, hvac_mode)
|
||||
@@ -246,7 +247,8 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac target hvac state."""
|
||||
# there are other power states that aren't 'on' but still count as on (eg. 'Turbo')
|
||||
# there are other power states that aren't 'on' but still
|
||||
# count as on (eg. 'Turbo')
|
||||
is_off = self._unit.PowerState == "Off"
|
||||
if is_off:
|
||||
return HVACMode.OFF
|
||||
|
||||
@@ -178,7 +178,8 @@ class Airtouch5AC(Airtouch5ClimateEntity):
|
||||
if ability.supports_fan_speed_intelligent_auto:
|
||||
self._attr_fan_modes.append(FAN_INTELLIGENT_AUTO)
|
||||
|
||||
# We can have different setpoints for heat cool, we expose the lowest low and highest high
|
||||
# We can have different setpoints for heat cool,
|
||||
# we expose the lowest low and highest high
|
||||
self._attr_min_temp = min(
|
||||
ability.min_cool_set_point, ability.min_heat_set_point
|
||||
)
|
||||
@@ -290,7 +291,8 @@ class Airtouch5Zone(Airtouch5ClimateEntity):
|
||||
manufacturer="Polyaire",
|
||||
model="AirTouch 5",
|
||||
)
|
||||
# We can have different setpoints for heat and cool, we expose the lowest low and highest high
|
||||
# We can have different setpoints for heat and cool,
|
||||
# we expose the lowest low and highest high
|
||||
self._attr_min_temp = min(ac.min_cool_set_point, ac.min_heat_set_point)
|
||||
self._attr_max_temp = max(ac.max_cool_set_point, ac.max_heat_set_point)
|
||||
|
||||
|
||||
@@ -34,8 +34,9 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors = {"base": "cannot_connect"}
|
||||
else:
|
||||
# Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed
|
||||
# pylint: disable-next=hass-unique-id-ip-based
|
||||
# Uses the host/IP value from CONF_HOST as unique ID,
|
||||
# which is no longer allowed
|
||||
# pylint: disable-next=home-assistant-unique-id-ip-based
|
||||
await self.async_set_unique_id(user_input[CONF_HOST])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
|
||||
@@ -75,7 +75,7 @@ def async_get_cloud_api_update_interval(
|
||||
def async_get_cloud_coordinators_by_api_key(
|
||||
hass: HomeAssistant, api_key: str
|
||||
) -> list[AirVisualDataUpdateCoordinator]:
|
||||
"""Get all AirVisualDataUpdateCoordinator objects related to a particular API key."""
|
||||
"""Get all coordinators related to a particular API key."""
|
||||
return [
|
||||
entry.runtime_data
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
@@ -24,7 +24,7 @@ class AsyncConfigFlowAuth(Auth):
|
||||
|
||||
|
||||
class AsyncConfigEntryAuth(Auth):
|
||||
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""
|
||||
"""Provide Aladdin Connect Genie auth tied to an OAuth2 config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -354,8 +354,9 @@ def _validate_zone_input(zone_input: dict[str, Any] | None) -> dict[str, str]:
|
||||
def _fix_input_types(zone_input: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert necessary keys to int.
|
||||
|
||||
Since ConfigFlow inputs of type int cannot default to an empty string, we collect the values below as
|
||||
strings and then convert them to ints.
|
||||
Since ConfigFlow inputs of type int cannot default to an empty
|
||||
string, we collect the values below as strings and then convert
|
||||
them to ints.
|
||||
"""
|
||||
|
||||
for key in (CONF_ZONE_LOOP, CONF_RELAY_ADDR, CONF_RELAY_CHAN):
|
||||
|
||||
@@ -1255,7 +1255,10 @@ async def async_api_set_mode(
|
||||
service = water_heater.SERVICE_SET_OPERATION_MODE
|
||||
data[water_heater.ATTR_OPERATION_MODE] = operation_mode
|
||||
else:
|
||||
msg = f"Entity '{entity.entity_id}' does not support Operation mode '{operation_mode}'"
|
||||
msg = (
|
||||
f"Entity '{entity.entity_id}' does not support"
|
||||
f" Operation mode '{operation_mode}'"
|
||||
)
|
||||
raise AlexaInvalidValueError(msg)
|
||||
|
||||
# Cover Position
|
||||
|
||||
@@ -224,7 +224,8 @@ def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]:
|
||||
resolved_data["id"] = possible_values[0]["id"]
|
||||
|
||||
# If there is only one match use the resolved value, otherwise the
|
||||
# resolution cannot be determined, so use the spoken slot value and empty string as id
|
||||
# resolution cannot be determined, so use the spoken slot
|
||||
# value and empty string as id
|
||||
if len(possible_values) == 1:
|
||||
resolved_data["value"] = possible_values[0]["name"]
|
||||
else:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from asyncio import timeout
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
@@ -11,7 +12,12 @@ from uuid import uuid4
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.components import event
|
||||
from homeassistant.const import EVENT_STATE_CHANGED, STATE_ON
|
||||
from homeassistant.const import (
|
||||
EVENT_STATE_CHANGED,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
@@ -51,6 +57,25 @@ DEFAULT_TIMEOUT = 10
|
||||
TO_REDACT = {"correlationToken", "token"}
|
||||
|
||||
|
||||
def valid_doorbell_timestamp(entity_id: str, event_state: str) -> bool:
|
||||
"""Check if doorbell event timestamp is valid."""
|
||||
if event_state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(event_state)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unable to parse ISO timestamp from state for %s. Got %s",
|
||||
entity_id,
|
||||
event_state,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
if (dt_util.utcnow() - timestamp) < timedelta(seconds=30):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class AlexaDirective:
|
||||
"""An incoming Alexa directive."""
|
||||
|
||||
@@ -315,9 +340,17 @@ async def async_enable_proactive_mode(
|
||||
|
||||
if should_doorbell:
|
||||
old_state = data["old_state"]
|
||||
if new_state.domain == event.DOMAIN or (
|
||||
if (
|
||||
new_state.domain == event.DOMAIN
|
||||
and valid_doorbell_timestamp(new_state.entity_id, new_state.state)
|
||||
and (old_state is None or old_state.state != STATE_UNAVAILABLE)
|
||||
and (old_state is None or old_state.state != new_state.state)
|
||||
) or (
|
||||
new_state.state == STATE_ON
|
||||
and (old_state is None or old_state.state != STATE_ON)
|
||||
and (
|
||||
old_state is None
|
||||
or old_state.state not in (STATE_ON, STATE_UNAVAILABLE)
|
||||
)
|
||||
):
|
||||
await async_send_doorbell_event_message(
|
||||
hass, smart_home_config, alexa_changed_entity
|
||||
|
||||
@@ -44,6 +44,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
|
||||
if entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 1 and entry.minor_version < 3:
|
||||
if CONF_SITE in entry.data:
|
||||
# Site in data (wrong place), just move to login data
|
||||
|
||||
@@ -27,9 +27,6 @@ COUNTRY_DOMAINS = {
|
||||
"za": "co.za",
|
||||
}
|
||||
|
||||
CATEGORY_SENSORS = "sensors"
|
||||
CATEGORY_NOTIFICATIONS = "notifications"
|
||||
|
||||
# Map service translation keys to Alexa API
|
||||
INFO_SKILLS_MAPPING = {
|
||||
"calendar_today": "Alexa.Calendar.PlayToday",
|
||||
|
||||
@@ -91,6 +91,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotAuthenticate as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.5.0"]
|
||||
"requirements": ["aioamazondevices==13.7.0"]
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import CATEGORY_NOTIFICATIONS, CATEGORY_SENSORS
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .entity import AmazonEntity
|
||||
from .utils import async_remove_unsupported_notification_sensors
|
||||
|
||||
@@ -38,30 +37,44 @@ from .utils import async_remove_unsupported_notification_sensors
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
type ValueFn = Callable[
|
||||
[AmazonDevice, str, AmazonDevicesCoordinator], StateType | datetime
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonSensorEntityDescription(SensorEntityDescription):
|
||||
"""Amazon Devices sensor entity description."""
|
||||
class AmazonBaseEntityDescription(SensorEntityDescription):
|
||||
"""Shared Amazon Devices entity description."""
|
||||
|
||||
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
|
||||
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
|
||||
device.online
|
||||
)
|
||||
value_fn: ValueFn
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonSensorEntityDescription(AmazonBaseEntityDescription):
|
||||
"""Amazon Devices sensor entity description."""
|
||||
|
||||
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
|
||||
device.online
|
||||
and (sensor := device.sensors.get(key)) is not None
|
||||
and sensor.error is False
|
||||
)
|
||||
category: str = CATEGORY_SENSORS
|
||||
value_fn: ValueFn = lambda device, key, _: device.sensors[key].value
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonNotificationEntityDescription(SensorEntityDescription):
|
||||
class AmazonNotificationEntityDescription(AmazonBaseEntityDescription):
|
||||
"""Amazon Devices notification entity description."""
|
||||
|
||||
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
|
||||
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
|
||||
device.online
|
||||
and (notification := device.notifications.get(key)) is not None
|
||||
and notification.next_occurrence is not None
|
||||
)
|
||||
category: str = CATEGORY_NOTIFICATIONS
|
||||
value_fn: ValueFn = lambda device, key, _: device.notifications[key].next_occurrence
|
||||
|
||||
|
||||
SENSORS: Final = (
|
||||
@@ -193,11 +206,11 @@ class AmazonSensorEntity(AmazonEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state of the sensor."""
|
||||
# Sensors
|
||||
if self.entity_description.category == CATEGORY_SENSORS:
|
||||
return self.device.sensors[self.entity_description.key].value
|
||||
# Notifications
|
||||
return self.device.notifications[self.entity_description.key].next_occurrence
|
||||
return self.entity_description.value_fn(
|
||||
self.device,
|
||||
self.entity_description.key,
|
||||
self.coordinator,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
DOMAIN = "altruist"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_HOST = "host"
|
||||
|
||||
@@ -21,7 +21,8 @@ API_URL = "https://app.amber.com.au/developers"
|
||||
|
||||
def generate_site_selector_name(site: Site) -> str:
|
||||
"""Generate the name to show in the site drop down in the configuration flow."""
|
||||
# For some reason the generated API key returns this as any, not a string. Thanks pydantic
|
||||
# For some reason the generated API key returns this as any,
|
||||
# not a string. Thanks pydantic
|
||||
nmi = str(site.nmi)
|
||||
if site.status == SiteStatus.CLOSED:
|
||||
if site.closed_on is None:
|
||||
|
||||
@@ -48,7 +48,7 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) ->
|
||||
|
||||
|
||||
class AmberUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read."""
|
||||
"""Coordinator in charge of downloading site data for all sensors."""
|
||||
|
||||
config_entry: AmberConfigEntry
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ DESCRIPTOR_MAP: dict[str, str] = {
|
||||
|
||||
|
||||
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
|
||||
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
|
||||
"""Return the snake case versions of descriptor names.
|
||||
|
||||
Returns None if the name is not recognized.
|
||||
"""
|
||||
if descriptor in DESCRIPTOR_MAP:
|
||||
return DESCRIPTOR_MAP[descriptor]
|
||||
return None
|
||||
|
||||
@@ -26,4 +26,5 @@ def get_station_name(station: dict[str, Any]) -> str:
|
||||
.get(API_STATION_LOCATION)
|
||||
)
|
||||
station_type = station.get(API_LAST_DATA, {}).get(API_STATION_TYPE)
|
||||
return f"{location}{'' if location is None or station_type is None else ' '}{station_type}"
|
||||
separator = "" if location is None or station_type is None else " "
|
||||
return f"{location}{separator}{station_type}"
|
||||
|
||||
@@ -192,7 +192,8 @@ class AmcrestBinarySensor(BinarySensorEntity):
|
||||
|
||||
if self._api.available:
|
||||
# Send a command to the camera to test if we can still communicate with it.
|
||||
# Override of Http.async_command() in __init__.py will set self._api.available
|
||||
# Override of Http.async_command() in __init__.py will
|
||||
# set self._api.available
|
||||
# accordingly.
|
||||
with suppress(AmcrestError):
|
||||
await self._api.async_current_time
|
||||
|
||||
@@ -461,7 +461,8 @@ class AmcrestCam(Camera):
|
||||
|
||||
async def _async_set_recording(self, enable: bool) -> None:
|
||||
rec_mode = {"Automatic": 0, "Manual": 1}
|
||||
# The property has a str type, but setter has int type, which causes mypy confusion
|
||||
# The property has a str type, but setter has int type,
|
||||
# which causes mypy confusion
|
||||
await self._api.async_set_record_mode(
|
||||
rec_mode["Manual" if enable else "Automatic"]
|
||||
)
|
||||
@@ -479,7 +480,8 @@ class AmcrestCam(Camera):
|
||||
return await self._api.async_is_motion_detector_on()
|
||||
|
||||
async def _async_set_motion_detection(self, enable: bool) -> None:
|
||||
# The property has a str type, but setter has bool type, which causes mypy confusion
|
||||
# The property has a str type, but setter has bool type,
|
||||
# which causes mypy confusion
|
||||
await self._api.async_set_motion_detection(enable)
|
||||
|
||||
async def _async_enable_motion_detection(self, enable: bool) -> None:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
from asyncio import timeout
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
||||
import contextlib
|
||||
from dataclasses import asdict as dataclass_asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
import random
|
||||
@@ -297,20 +298,24 @@ class Analytics:
|
||||
if stored:
|
||||
self._data = AnalyticsData.from_dict(stored)
|
||||
|
||||
if (
|
||||
self.supervisor
|
||||
and (supervisor_info := hassio.get_supervisor_info(self._hass)) is not None
|
||||
):
|
||||
if not self.onboarded:
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
if supervisor_info[ATTR_DIAGNOSTICS] and not self.preferences.get(
|
||||
ATTR_DIAGNOSTICS, False
|
||||
):
|
||||
self._data.preferences[ATTR_DIAGNOSTICS] = True
|
||||
elif not supervisor_info[ATTR_DIAGNOSTICS] and self.preferences.get(
|
||||
ATTR_DIAGNOSTICS, False
|
||||
):
|
||||
self._data.preferences[ATTR_DIAGNOSTICS] = False
|
||||
if self.supervisor and not self.onboarded:
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable
|
||||
# during setup of the Supervisor integration. That will fail setup
|
||||
# of this integration. However there is no better option at this time
|
||||
# since we need to get the diagnostic setting from Supervisor to correctly
|
||||
# setup this integration and we can't raise ConfigEntryNotReady to
|
||||
# trigger a retry from async_setup.
|
||||
supervisor_info = hassio.get_supervisor_info(self._hass)
|
||||
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
if supervisor_info[ATTR_DIAGNOSTICS] and not self.preferences.get(
|
||||
ATTR_DIAGNOSTICS, False
|
||||
):
|
||||
self._data.preferences[ATTR_DIAGNOSTICS] = True
|
||||
elif not supervisor_info[ATTR_DIAGNOSTICS] and self.preferences.get(
|
||||
ATTR_DIAGNOSTICS, False
|
||||
):
|
||||
self._data.preferences[ATTR_DIAGNOSTICS] = False
|
||||
|
||||
async def _save(self) -> None:
|
||||
"""Save data."""
|
||||
@@ -344,9 +349,14 @@ class Analytics:
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
# get_supervisor_info was called during setup so we can't get here
|
||||
# if it raised. The others may raise HassioNotReadyError if only some
|
||||
# data was successfully fetched from Supervisor
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
operating_system_info = hassio.get_os_info(hass) or {}
|
||||
addons_info = hassio.get_addons_info(hass) or {}
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
operating_system_info = hassio.get_os_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
addons_info = hassio.get_addons_info(hass)
|
||||
|
||||
system_info = await async_get_system_info(hass)
|
||||
integrations = []
|
||||
@@ -419,7 +429,7 @@ class Analytics:
|
||||
|
||||
integrations.append(integration.domain)
|
||||
|
||||
if addons_info is not None:
|
||||
if addons_info:
|
||||
supervisor_client = hassio.get_supervisor_client(hass)
|
||||
installed_addons = await asyncio.gather(
|
||||
*(supervisor_client.addons.addon_info(slug) for slug in addons_info)
|
||||
@@ -602,7 +612,8 @@ class Analytics:
|
||||
|
||||
else:
|
||||
LOGGER.warning(
|
||||
"Unexpected status code %s when submitting snapshot analytics to %s",
|
||||
"Unexpected status code %s when submitting"
|
||||
" snapshot analytics to %s",
|
||||
response.status,
|
||||
url,
|
||||
)
|
||||
@@ -804,7 +815,8 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
|
||||
if not isinstance(integration_config, AnalyticsModifications):
|
||||
LOGGER.error( # type: ignore[unreachable]
|
||||
"Calling async_modify_analytics for integration '%s' did not return an AnalyticsConfig",
|
||||
"Calling async_modify_analytics for integration"
|
||||
" '%s' did not return an AnalyticsConfig",
|
||||
integration_domain,
|
||||
)
|
||||
integration_configs[integration_domain] = AnalyticsModifications(
|
||||
@@ -818,7 +830,8 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
|
||||
# We need to refer to other devices, for example in `via_device` field.
|
||||
# We don't however send the original device ids outside of Home Assistant,
|
||||
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
|
||||
# instead we refer to devices by
|
||||
# (integration_domain, index_in_integration_device_list).
|
||||
device_id_mapping: dict[str, tuple[str, int]] = {}
|
||||
|
||||
# Fill out information about devices
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import SectionConfig, section
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
ObjectSelector,
|
||||
@@ -32,6 +33,7 @@ from .const import (
|
||||
CONF_APPS,
|
||||
CONF_EXCLUDE_UNNAMED_APPS,
|
||||
CONF_GET_SOURCES,
|
||||
CONF_MORE_OPTIONS,
|
||||
CONF_SCREENCAP_INTERVAL,
|
||||
CONF_STATE_DETECTION_RULES,
|
||||
CONF_TURN_OFF_COMMAND,
|
||||
@@ -97,20 +99,22 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Required(CONF_MORE_OPTIONS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_ADBKEY): str,
|
||||
vol.Optional(CONF_ADB_SERVER_IP): str,
|
||||
vol.Optional(
|
||||
CONF_ADB_SERVER_PORT,
|
||||
default=DEFAULT_ADB_SERVER_PORT,
|
||||
): cv.port,
|
||||
}
|
||||
),
|
||||
SectionConfig(collapsed=True),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if self.show_advanced_options:
|
||||
data_schema = data_schema.extend(
|
||||
{
|
||||
vol.Optional(CONF_ADBKEY): str,
|
||||
vol.Optional(CONF_ADB_SERVER_IP): str,
|
||||
vol.Required(
|
||||
CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT
|
||||
): cv.port,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
@@ -155,6 +159,10 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
error = None
|
||||
|
||||
if user_input is not None:
|
||||
user_input = user_input.copy()
|
||||
more_options = user_input.pop(CONF_MORE_OPTIONS, {})
|
||||
user_input.update(more_options)
|
||||
|
||||
host = user_input[CONF_HOST]
|
||||
adb_key = user_input.get(CONF_ADBKEY)
|
||||
if CONF_ADB_SERVER_IP in user_input:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
DOMAIN = "androidtv"
|
||||
|
||||
CONF_ADB_SERVER_IP = "adb_server_ip"
|
||||
CONF_MORE_OPTIONS = "more_options"
|
||||
CONF_ADB_SERVER_PORT = "adb_server_port"
|
||||
CONF_ADBKEY = "adbkey"
|
||||
CONF_APPS = "apps"
|
||||
|
||||
@@ -94,10 +94,9 @@ def adb_decorator[_ADBDeviceT: AndroidTVEntity, **_P, _R](
|
||||
# it doesn't happen over and over again.
|
||||
if self.available:
|
||||
_LOGGER.error(
|
||||
(
|
||||
"Unexpected exception executing an ADB command. ADB connection"
|
||||
" re-establishing attempt in the next update. Error: %s"
|
||||
),
|
||||
"Unexpected exception executing an ADB"
|
||||
" command. ADB connection re-establishing"
|
||||
" attempt in the next update. Error: %s",
|
||||
err,
|
||||
)
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||
|
||||
@adb_decorator()
|
||||
async def service_download(self, device_path: str, local_path: str) -> None:
|
||||
"""Download a file from your Android / Fire TV device to your Home Assistant instance."""
|
||||
"""Download a file from your Android / Fire TV device."""
|
||||
if not self.hass.config.is_allowed_path(local_path):
|
||||
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
|
||||
return
|
||||
@@ -290,7 +290,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||
|
||||
@adb_decorator()
|
||||
async def service_upload(self, device_path: str, local_path: str) -> None:
|
||||
"""Upload a file from your Home Assistant instance to an Android / Fire TV device."""
|
||||
"""Upload a file to an Android / Fire TV device."""
|
||||
if not self.hass.config.is_allowed_path(local_path):
|
||||
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
|
||||
return
|
||||
|
||||
@@ -14,12 +14,19 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"adb_server_ip": "IP address of the ADB server (leave empty to not use)",
|
||||
"adb_server_port": "Port of the ADB server",
|
||||
"adbkey": "Path to your ADB key file (leave empty to auto generate)",
|
||||
"device_class": "The type of device",
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"sections": {
|
||||
"more_options": {
|
||||
"data": {
|
||||
"adb_server_ip": "IP address of the ADB server (leave empty to not use)",
|
||||
"adb_server_port": "Port of the ADB server",
|
||||
"adbkey": "Path to your ADB key file (leave empty to auto generate)"
|
||||
},
|
||||
"name": "More options"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,9 @@ async def async_setup_entry(
|
||||
# The Android TV is hard reset or the certificate and key files were deleted.
|
||||
raise ConfigEntryAuthFailed from exc
|
||||
except (CannotConnect, ConnectionClosed, TimeoutError) as exc:
|
||||
# The Android TV is network unreachable. Raise exception and let Home Assistant retry
|
||||
# later. If device gets a new IP address the zeroconf flow will update the config.
|
||||
# The Android TV is network unreachable. Raise exception and
|
||||
# let Home Assistant retry later. If device gets a new IP
|
||||
# address the zeroconf flow will update the config.
|
||||
raise ConfigEntryNotReady from exc
|
||||
|
||||
def reauth_needed() -> None:
|
||||
|
||||
@@ -107,7 +107,10 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
async def _async_start_pair(self) -> ConfigFlowResult:
|
||||
"""Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen."""
|
||||
"""Start pairing with the Android TV.
|
||||
|
||||
Navigate to the pair flow to enter the PIN shown on screen.
|
||||
"""
|
||||
self.api = create_api(self.hass, self.host, enable_ime=False)
|
||||
await self.api.async_generate_cert_if_missing()
|
||||
await self.api.async_start_pairing()
|
||||
@@ -135,9 +138,10 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return await self._async_start_pair()
|
||||
except CannotConnect, ConnectionClosed:
|
||||
# Device doesn't respond to the specified host. Abort.
|
||||
# If we are in the user flow we could go back to the user step to allow
|
||||
# them to enter a new IP address but we cannot do that for the zeroconf
|
||||
# flow. Simpler to abort for both flows.
|
||||
# If we are in the user flow we could go back
|
||||
# to the user step to allow them to enter a
|
||||
# new IP address but we cannot do that for the
|
||||
# zeroconf flow. Simpler to abort for both.
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
else:
|
||||
if self.source == SOURCE_REAUTH:
|
||||
|
||||
@@ -42,7 +42,7 @@ class AndroidTVRemoteBaseEntity(Entity):
|
||||
|
||||
@callback
|
||||
def _is_available_updated(self, is_available: bool) -> None:
|
||||
"""Update the state when the device is ready to receive commands or is unavailable."""
|
||||
"""Update the state when the device is ready or unavailable."""
|
||||
self._attr_available = is_available
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -65,7 +65,8 @@ class AndroidTVRemoteBaseEntity(Entity):
|
||||
def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None:
|
||||
"""Send a key press to Android TV.
|
||||
|
||||
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
|
||||
This does not block; it buffers the data and arranges
|
||||
for it to be sent out asynchronously.
|
||||
"""
|
||||
try:
|
||||
self._api.send_key_command(key_code, direction)
|
||||
@@ -77,7 +78,8 @@ class AndroidTVRemoteBaseEntity(Entity):
|
||||
def _send_launch_app_command(self, app_link: str) -> None:
|
||||
"""Launch an app on Android TV.
|
||||
|
||||
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
|
||||
This does not block; it buffers the data and arranges
|
||||
for it to be sent out asynchronously.
|
||||
"""
|
||||
try:
|
||||
self._api.send_launch_app_command(app_link)
|
||||
|
||||
@@ -95,8 +95,10 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
if not meter.readings or len(meter.readings) == 0:
|
||||
_LOGGER.debug("No recent usage statistics found, skipping update")
|
||||
continue
|
||||
# Anglian Water stats are hourly, the read_at time is the time that the meter took the reading
|
||||
# We remove 1 hour from this so that the data is shown in the correct hour on the dashboards
|
||||
# Anglian Water stats are hourly, the read_at time
|
||||
# is the time that the meter took the reading.
|
||||
# We remove 1 hour from this so that the data is
|
||||
# shown in the correct hour on the dashboards
|
||||
parsed_read_at = dt_util.parse_datetime(meter.readings[0]["read_at"])
|
||||
if not parsed_read_at:
|
||||
_LOGGER.debug(
|
||||
@@ -130,8 +132,9 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
if not stats or not stats.get(usage_statistic_id):
|
||||
_LOGGER.debug(
|
||||
"Could not find existing statistics during period lookup for %s, "
|
||||
"falling back to last stored statistic",
|
||||
"Could not find existing statistics during"
|
||||
" period lookup for %s, falling back to"
|
||||
" last stored statistic",
|
||||
usage_statistic_id,
|
||||
)
|
||||
allow_update_last_stored_hour = True
|
||||
|
||||
@@ -43,7 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> boo
|
||||
except NoDevicesFound as err:
|
||||
# Can later setup successfully and spawn a repair.
|
||||
raise ConfigEntryNotReady(
|
||||
"No devices were found on the websocket, perhaps you don't have any devices on this account?"
|
||||
"No devices were found on the websocket, perhaps you"
|
||||
" don't have any devices on this account?"
|
||||
) from err
|
||||
except WebsocketFailure as err:
|
||||
raise ConfigEntryNotReady("Failed connecting to the websocket.") from err
|
||||
|
||||
@@ -16,6 +16,8 @@ from .entity import AnthropicBaseLLMEntity
|
||||
if TYPE_CHECKING:
|
||||
from . import AnthropicConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LLM_HASS_API,
|
||||
CONF_NAME,
|
||||
CONF_PROMPT,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import llm
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers import config_validation as cv, llm
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
@@ -44,12 +44,13 @@ from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_CODE_EXECUTION,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT,
|
||||
CONF_PROMPT_CACHING,
|
||||
CONF_RECOMMENDED,
|
||||
CONF_THINKING_BUDGET,
|
||||
CONF_THINKING_EFFORT,
|
||||
CONF_TOOL_SEARCH,
|
||||
CONF_WEB_FETCH,
|
||||
CONF_WEB_FETCH_MAX_USES,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
@@ -452,11 +453,19 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_MAX_USES,
|
||||
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
|
||||
): int,
|
||||
): cv.positive_int,
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_WEB_FETCH,
|
||||
default=DEFAULT[CONF_WEB_FETCH],
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_WEB_FETCH_MAX_USES,
|
||||
default=DEFAULT[CONF_WEB_FETCH_MAX_USES],
|
||||
): cv.positive_int,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -546,7 +555,9 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
description="Free text input for the city, e.g. `San Francisco`",
|
||||
description=(
|
||||
"Free text input for the city, e.g. `San Francisco`"
|
||||
),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
|
||||
@@ -10,7 +10,6 @@ DEFAULT_CONVERSATION_NAME = "Claude conversation"
|
||||
DEFAULT_AI_TASK_NAME = "Claude AI Task"
|
||||
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_PROMPT = "prompt"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
CONF_CODE_EXECUTION = "code_execution"
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
@@ -18,6 +17,8 @@ CONF_PROMPT_CACHING = "prompt_caching"
|
||||
CONF_THINKING_BUDGET = "thinking_budget"
|
||||
CONF_THINKING_EFFORT = "thinking_effort"
|
||||
CONF_TOOL_SEARCH = "tool_search"
|
||||
CONF_WEB_FETCH = "web_fetch"
|
||||
CONF_WEB_FETCH_MAX_USES = "web_fetch_max_uses"
|
||||
CONF_WEB_SEARCH = "web_search"
|
||||
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
|
||||
CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses"
|
||||
@@ -45,6 +46,8 @@ DEFAULT = {
|
||||
CONF_THINKING_BUDGET: MIN_THINKING_BUDGET,
|
||||
CONF_THINKING_EFFORT: "low",
|
||||
CONF_TOOL_SEARCH: False,
|
||||
CONF_WEB_FETCH: False,
|
||||
CONF_WEB_FETCH_MAX_USES: 5,
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
|
||||
@@ -4,14 +4,16 @@ from typing import Literal
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import CONF_PROMPT, DOMAIN
|
||||
from .const import DOMAIN
|
||||
from .entity import AnthropicBaseLLMEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -34,7 +34,7 @@ def model_alias(model_id: str) -> str:
|
||||
|
||||
|
||||
class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]):
|
||||
"""DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates."""
|
||||
"""Coordinator using different intervals after success and failure."""
|
||||
|
||||
client: anthropic.AsyncAnthropic
|
||||
|
||||
|
||||
@@ -5,11 +5,10 @@ from typing import TYPE_CHECKING, Any
|
||||
from anthropic import __title__, __version__
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PROMPT
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import (
|
||||
CONF_PROMPT,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
|
||||
@@ -17,8 +17,6 @@ from anthropic.types import (
|
||||
Base64PDFSourceParam,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
CitationsDelta,
|
||||
CitationsWebSearchResultLocation,
|
||||
CitationWebSearchResultLocationParam,
|
||||
CodeExecutionTool20250825Param,
|
||||
CodeExecutionToolResultBlock,
|
||||
CodeExecutionToolResultBlockContent,
|
||||
@@ -70,6 +68,9 @@ from anthropic.types import (
|
||||
ToolUseBlock,
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
WebFetchTool20250910Param,
|
||||
WebFetchTool20260209Param,
|
||||
WebFetchToolResultBlock,
|
||||
WebSearchTool20250305Param,
|
||||
WebSearchTool20260209Param,
|
||||
WebSearchToolResultBlock,
|
||||
@@ -97,6 +98,12 @@ from anthropic.types.tool_search_tool_result_block_param import (
|
||||
Content as ToolSearchToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.tool_use_block import Caller
|
||||
from anthropic.types.web_fetch_tool_result_block import (
|
||||
Content as WebFetchToolResultBlockContent,
|
||||
)
|
||||
from anthropic.types.web_fetch_tool_result_block_param import (
|
||||
Content as WebFetchToolResultBlockParamContentParam,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
@@ -118,6 +125,8 @@ from .const import (
|
||||
CONF_THINKING_BUDGET,
|
||||
CONF_THINKING_EFFORT,
|
||||
CONF_TOOL_SEARCH,
|
||||
CONF_WEB_FETCH,
|
||||
CONF_WEB_FETCH_MAX_USES,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
@@ -208,17 +217,9 @@ class ContentDetails:
|
||||
"""Add a citation to the current detail."""
|
||||
if not self.citation_details:
|
||||
self.citation_details.append(CitationDetails())
|
||||
citation_param: TextCitationParam | None = None
|
||||
if isinstance(citation, CitationsWebSearchResultLocation):
|
||||
citation_param = CitationWebSearchResultLocationParam(
|
||||
type="web_search_result_location",
|
||||
title=citation.title,
|
||||
url=citation.url,
|
||||
cited_text=citation.cited_text,
|
||||
encrypted_index=citation.encrypted_index,
|
||||
)
|
||||
if citation_param:
|
||||
self.citation_details[-1].citations.append(citation_param)
|
||||
self.citation_details[-1].citations.append(
|
||||
cast(TextCitationParam, citation.to_dict())
|
||||
)
|
||||
|
||||
def delete_empty(self) -> None:
|
||||
"""Delete empty citation details."""
|
||||
@@ -289,6 +290,15 @@ def _convert_content( # noqa: C901
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "web_fetch":
|
||||
tool_result_block = {
|
||||
"type": "web_fetch_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
WebFetchToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
else:
|
||||
tool_result_block = {
|
||||
"type": "tool_result",
|
||||
@@ -415,6 +425,7 @@ def _convert_content( # noqa: C901
|
||||
id=tool_call.id,
|
||||
name=cast(
|
||||
Literal[
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
@@ -428,6 +439,7 @@ def _convert_content( # noqa: C901
|
||||
if tool_call.external
|
||||
and tool_call.tool_name
|
||||
in [
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
@@ -452,7 +464,8 @@ def _convert_content( # noqa: C901
|
||||
# If there is only one text block, simplify the content to a string
|
||||
messages[-1]["content"] = messages[-1]["content"][0]["text"]
|
||||
else:
|
||||
# Note: We don't pass SystemContent here as it's passed to the API as the prompt
|
||||
# Note: We don't pass SystemContent here as it's
|
||||
# passed to the API as the prompt
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unexpected_chat_log_content",
|
||||
@@ -467,7 +480,8 @@ class AnthropicDeltaStream:
|
||||
|
||||
A typical stream of responses might look something like the following:
|
||||
- RawMessageStartEvent with no content
|
||||
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
|
||||
- RawContentBlockStartEvent with an empty ThinkingBlock
|
||||
(if extended thinking is enabled)
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
@@ -607,6 +621,7 @@ class AnthropicDeltaStream:
|
||||
if isinstance(
|
||||
content_block,
|
||||
(
|
||||
WebFetchToolResultBlock,
|
||||
WebSearchToolResultBlock,
|
||||
CodeExecutionToolResultBlock,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
@@ -646,7 +661,8 @@ class AnthropicDeltaStream:
|
||||
|
||||
def on_text_block(self, text: str, citations: list[TextCitation] | None) -> None:
|
||||
"""Handle TextBlock."""
|
||||
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
|
||||
if ( # Do not start a new assistant content just for
|
||||
# citations, concatenate consecutive blocks instead.
|
||||
self._first_block
|
||||
or (
|
||||
not self._content_details.has_citations()
|
||||
@@ -721,13 +737,15 @@ class AnthropicDeltaStream:
|
||||
self,
|
||||
tool_use_id: str,
|
||||
tool_name: Literal[
|
||||
"web_fetch_tool_result",
|
||||
"web_search_tool_result",
|
||||
"code_execution_tool_result",
|
||||
"bash_code_execution_tool_result",
|
||||
"text_editor_code_execution_tool_result",
|
||||
"tool_search_tool_result",
|
||||
],
|
||||
content: WebSearchToolResultBlockContent
|
||||
content: WebFetchToolResultBlockContent
|
||||
| WebSearchToolResultBlockContent
|
||||
| CodeExecutionToolResultBlockContent
|
||||
| BashCodeExecutionToolResultBlockContent
|
||||
| TextEditorCodeExecutionToolResultBlockContent
|
||||
@@ -904,6 +922,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
"GetLiveContext",
|
||||
"code_execution",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
]
|
||||
|
||||
system = chat_log.content[0]
|
||||
@@ -977,11 +996,12 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
]
|
||||
|
||||
if options[CONF_CODE_EXECUTION]:
|
||||
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
|
||||
# The `web_search_20260209` and `web_fetch_20260209` tools
|
||||
# automatically enable `code_execution_20260120` tool
|
||||
if (
|
||||
not self.model_info.capabilities
|
||||
or not self.model_info.capabilities.code_execution.supported
|
||||
or not options[CONF_WEB_SEARCH]
|
||||
or (not options[CONF_WEB_SEARCH] and not options[CONF_WEB_FETCH])
|
||||
):
|
||||
tools.append(
|
||||
CodeExecutionTool20250825Param(
|
||||
@@ -1019,6 +1039,28 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
}
|
||||
tools.append(web_search)
|
||||
|
||||
if options[CONF_WEB_FETCH]:
|
||||
if (
|
||||
not self.model_info.capabilities
|
||||
or not self.model_info.capabilities.code_execution.supported
|
||||
or not options[CONF_CODE_EXECUTION]
|
||||
):
|
||||
tools.append(
|
||||
WebFetchTool20250910Param(
|
||||
name="web_fetch",
|
||||
type="web_fetch_20250910",
|
||||
max_uses=options[CONF_WEB_FETCH_MAX_USES],
|
||||
)
|
||||
)
|
||||
else:
|
||||
tools.append(
|
||||
WebFetchTool20260209Param(
|
||||
name="web_fetch",
|
||||
type="web_fetch_20260209",
|
||||
max_uses=options[CONF_WEB_FETCH_MAX_USES],
|
||||
)
|
||||
)
|
||||
|
||||
# Handle attachments by adding them to the last user message
|
||||
last_content = chat_log.content[-1]
|
||||
if last_content.role == "user" and last_content.attachments:
|
||||
@@ -1159,7 +1201,8 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
)
|
||||
cast(list[MessageParam], model_args["messages"]).extend(new_messages)
|
||||
except anthropic.AuthenticationError as err:
|
||||
# Trigger coordinator to confirm the auth failure and trigger the reauth flow.
|
||||
# Trigger coordinator to confirm the auth failure
|
||||
# and trigger the reauth flow.
|
||||
await coordinator.async_request_refresh()
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -38,10 +38,7 @@ rules:
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: |
|
||||
The API does not limit parallel updates.
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
# Gold
|
||||
|
||||
@@ -40,9 +40,11 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
self._current_subentry_id = None
|
||||
self._model_list_cache = None
|
||||
|
||||
async def async_step_init(self, user_input: dict[str, str]) -> RepairsFlowResult:
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the steps of a fix flow."""
|
||||
if user_input.get(CONF_CHAT_MODEL):
|
||||
if user_input and user_input.get(CONF_CHAT_MODEL):
|
||||
self._async_update_current_subentry(user_input)
|
||||
|
||||
target = await self._async_next_target()
|
||||
|
||||
@@ -51,13 +51,11 @@
|
||||
"advanced": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]",
|
||||
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]"
|
||||
},
|
||||
"data_description": {
|
||||
"chat_model": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::chat_model%]",
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]",
|
||||
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::temperature%]"
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]"
|
||||
},
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
|
||||
},
|
||||
@@ -80,6 +78,8 @@
|
||||
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]",
|
||||
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::tool_search%]",
|
||||
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
|
||||
"web_fetch": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_fetch%]",
|
||||
"web_fetch_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_fetch_max_uses%]",
|
||||
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
|
||||
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
|
||||
},
|
||||
@@ -90,6 +90,8 @@
|
||||
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]",
|
||||
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::tool_search%]",
|
||||
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
|
||||
"web_fetch": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_fetch%]",
|
||||
"web_fetch_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_fetch_max_uses%]",
|
||||
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
|
||||
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
|
||||
},
|
||||
@@ -116,13 +118,11 @@
|
||||
"advanced": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"prompt_caching": "Caching strategy",
|
||||
"temperature": "Temperature"
|
||||
"prompt_caching": "Caching strategy"
|
||||
},
|
||||
"data_description": {
|
||||
"chat_model": "The model to serve the responses.",
|
||||
"prompt_caching": "Optimize your API cost and response times based on your usage.",
|
||||
"temperature": "Control the randomness of the response, trading off between creativity and coherence."
|
||||
"prompt_caching": "Optimize your API cost and response times based on your usage."
|
||||
},
|
||||
"title": "Advanced settings"
|
||||
},
|
||||
@@ -149,6 +149,8 @@
|
||||
"thinking_effort": "Thinking effort",
|
||||
"tool_search": "Enable tool search tool",
|
||||
"user_location": "Include home location",
|
||||
"web_fetch": "Enable web fetch",
|
||||
"web_fetch_max_uses": "Maximum web fetches",
|
||||
"web_search": "Enable web search",
|
||||
"web_search_max_uses": "Maximum web searches"
|
||||
},
|
||||
@@ -159,6 +161,8 @@
|
||||
"thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency",
|
||||
"tool_search": "Enable dynamic tool discovery instead of preloading all tools into the context",
|
||||
"user_location": "Localize search results based on home location",
|
||||
"web_fetch": "The web fetch tool allows Claude to retrieve full content from specified web pages and PDF documents to augment Claude's context with live web content",
|
||||
"web_fetch_max_uses": "Limit the number of web fetches performed per response",
|
||||
"web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff",
|
||||
"web_search_max_uses": "Limit the number of searches performed per response"
|
||||
},
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aosmith",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["py-aosmith==1.0.17"]
|
||||
"requirements": ["py-aosmith==1.0.18"]
|
||||
}
|
||||
|
||||
@@ -45,7 +45,10 @@ class AOSmithHotWaterPlusSelectEntity(AOSmithStatusEntity, SelectEntity):
|
||||
|
||||
@property
|
||||
def suggested_object_id(self) -> str | None:
|
||||
"""Override the suggested object id to make '+' get converted to 'plus' in the entity id."""
|
||||
"""Override the suggested object id.
|
||||
|
||||
Makes '+' get converted to 'plus' in the entity id.
|
||||
"""
|
||||
return "hot_water_plus_level"
|
||||
|
||||
@property
|
||||
|
||||
@@ -54,7 +54,8 @@ class OnlineStatus(APCUPSdEntity, BinarySensorEntity):
|
||||
"""Returns true if the UPS is online."""
|
||||
# Check if ONLINE bit is set in STATFLAG.
|
||||
key = self.entity_description.key.upper()
|
||||
# The daemon could either report just a hex ("0x05000008"), or a hex with a "Status Flag"
|
||||
# The daemon could either report just a hex
|
||||
# ("0x05000008"), or a hex with a "Status Flag"
|
||||
# suffix ("0x05000008 Status Flag") in older versions.
|
||||
# Here we trim the suffix if it exists to support both.
|
||||
flag = self.coordinator.data[key].removesuffix(" Status Flag")
|
||||
|
||||
@@ -8,7 +8,8 @@ CONNECTION_TIMEOUT: int = 10
|
||||
# Field name of last self test retrieved from apcupsd.
|
||||
LAST_S_TEST: Final = "laststest"
|
||||
|
||||
# Mapping of deprecated sensor keys (as reported by apcupsd, lower-cased) to their deprecation
|
||||
# Mapping of deprecated sensor keys (as reported by apcupsd,
|
||||
# lower-cased) to their deprecation
|
||||
# repair issue translation keys.
|
||||
DEPRECATED_SENSORS: Final = {
|
||||
"apc": "apc_deprecated",
|
||||
|
||||
@@ -27,7 +27,7 @@ type APCUPSdConfigEntry = ConfigEntry[APCUPSdCoordinator]
|
||||
|
||||
|
||||
class APCUPSdData(dict[str, str]):
|
||||
"""Store data about an APCUPSd and provide a few helper methods for easier accesses."""
|
||||
"""Store data about an APCUPSd and provide helper methods."""
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
@@ -45,8 +45,9 @@ class APCUPSdData(dict[str, str]):
|
||||
def serial_no(self) -> str | None:
|
||||
"""Return the unique serial number of the UPS, if available."""
|
||||
sn = self.get("SERIALNO")
|
||||
# We had user reports that some UPS models simply return "Blank" as serial number, in
|
||||
# which case we fall back to `None` to indicate that it is actually not available.
|
||||
# We had user reports that some UPS models simply return
|
||||
# "Blank" as serial number, in which case we fall back to
|
||||
# `None` to indicate that it is actually not available.
|
||||
return None if sn == "Blank" else sn
|
||||
|
||||
|
||||
@@ -85,7 +86,11 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]):
|
||||
|
||||
@property
|
||||
def unique_device_id(self) -> str:
|
||||
"""Return a unique ID of the device, which is the serial number (if available) or the config entry ID."""
|
||||
"""Return a unique ID of the device.
|
||||
|
||||
Uses the serial number if available, otherwise the
|
||||
config entry ID.
|
||||
"""
|
||||
return self.data.serial_no or self.config_entry.entry_id
|
||||
|
||||
@property
|
||||
|
||||
@@ -473,13 +473,16 @@ async def async_setup_entry(
|
||||
|
||||
entities = []
|
||||
|
||||
# "laststest" is a special sensor that only appears when the APC UPS daemon has done a
|
||||
# periodical (or manual) self test since last daemon restart. It might not be available
|
||||
# when we set up the integration, and we do not know if it would ever be available. Here we
|
||||
# add it anyway and mark it as unknown initially.
|
||||
# "laststest" is a special sensor that only appears when
|
||||
# the APC UPS daemon has done a periodical (or manual) self
|
||||
# test since last daemon restart. It might not be available
|
||||
# when we set up the integration, and we do not know if it
|
||||
# would ever be available. Here we add it anyway and mark it
|
||||
# as unknown initially.
|
||||
#
|
||||
# We also sort the resources to ensure the order of entities created is deterministic since
|
||||
# "APCMODEL" and "MODEL" resources map to the same "Model" name.
|
||||
# We also sort the resources to ensure the order of entities
|
||||
# created is deterministic since "APCMODEL" and "MODEL"
|
||||
# resources map to the same "Model" name.
|
||||
for resource in sorted(available_resources | {LAST_S_TEST}):
|
||||
if resource not in SENSORS:
|
||||
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
||||
@@ -527,9 +530,11 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update sensor attributes based on coordinator data."""
|
||||
key = self.entity_description.key.upper()
|
||||
# For most sensors the key will always be available for each refresh. However, some sensors
|
||||
# (e.g., "laststest") will only appear after certain event occurs (e.g., a self test is
|
||||
# performed) and may disappear again after certain event. So we mark the state as "unknown"
|
||||
# For most sensors the key will always be available for
|
||||
# each refresh. However, some sensors (e.g., "laststest")
|
||||
# will only appear after certain event occurs (e.g., a
|
||||
# self test is performed) and may disappear again after
|
||||
# certain event. So we mark the state as "unknown"
|
||||
# when it becomes unknown after such events.
|
||||
if key not in self.coordinator.data:
|
||||
self._attr_native_value = None
|
||||
@@ -538,7 +543,8 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
data = self.coordinator.data[key]
|
||||
|
||||
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
|
||||
# The date could be "N/A" for certain fields (e.g., XOFFBATT), indicating there is no value yet.
|
||||
# The date could be "N/A" for certain fields
|
||||
# (e.g., XOFFBATT), indicating there is no value yet.
|
||||
if data == "N/A":
|
||||
self._attr_native_value = None
|
||||
return
|
||||
@@ -546,7 +552,8 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
try:
|
||||
self._attr_native_value = dateutil.parser.parse(data)
|
||||
except dateutil.parser.ParserError, OverflowError:
|
||||
# If parsing fails we should mark it as unknown, with a log for further debugging.
|
||||
# If parsing fails we should mark it as unknown,
|
||||
# with a log for further debugging.
|
||||
_LOGGER.warning('Failed to parse date for %s: "%s"', key, data)
|
||||
self._attr_native_value = None
|
||||
return
|
||||
@@ -578,7 +585,8 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
entity_registry = er.async_get(self.hass)
|
||||
items = [
|
||||
f"- [{entry.name or entry.original_name or entity_id}]"
|
||||
f"(/config/{integration}/edit/{entry.unique_id or entity_id.split('.', 1)[-1]})"
|
||||
f"(/config/{integration}/edit/"
|
||||
f"{entry.unique_id or entity_id.split('.', 1)[-1]})"
|
||||
for integration, entities in (
|
||||
("automation", automations),
|
||||
("script", scripts),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user