mirror of
https://github.com/home-assistant/core.git
synced 2026-05-28 19:53:18 +02:00
Compare commits
570 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef1bbc4e0b | |||
| 849cd14233 | |||
| 4668800cd3 | |||
| 5f495d725b | |||
| 9e1741067a | |||
| a1da79314a | |||
| 6e1c6393aa | |||
| 4fe38d3f51 | |||
| c07fed05df | |||
| 13ef737873 | |||
| 0a1510135c | |||
| 6f6b7888cd | |||
| b9173e36fb | |||
| a65ca9c86b | |||
| fc12d6fbb6 | |||
| 2a6b686254 | |||
| 4d841e4d84 | |||
| df08e9f311 | |||
| d53e40eea8 | |||
| 0b261b7198 | |||
| 3a9f32de25 | |||
| b5e54583c7 | |||
| 85ea7c1176 | |||
| 713f520bc8 | |||
| e4bb5a9395 | |||
| 936b2fe933 | |||
| c6c6f08885 | |||
| c621721851 | |||
| 5bb6b20641 | |||
| 37f41d8e09 | |||
| b02f312bed | |||
| 3520c821c5 | |||
| cbf737a03e | |||
| 5bd6d52e6a | |||
| d9a89beb3d | |||
| 41f783f14d | |||
| 35397b818d | |||
| d42d02f20a | |||
| 99c445f261 | |||
| 567fe85828 | |||
| fd1a5d0c5a | |||
| 632ec39d53 | |||
| 67b9d28953 | |||
| e3880eedb0 | |||
| ce64f5f902 | |||
| 0da99a50fc | |||
| 43f636be65 | |||
| 262cdbfab5 | |||
| 8cbd358435 | |||
| df04b19a0a | |||
| adeb352079 | |||
| 1e457600f1 | |||
| 51d1d4aa9e | |||
| 8184b93151 | |||
| 403cb85bc8 | |||
| 4bf3a5b4bd | |||
| 5a73d78c90 | |||
| ebd9934213 | |||
| 73898c29e2 | |||
| 3372bf45ec | |||
| 9744388a4e | |||
| 75c52a382e | |||
| f8a65a7c6f | |||
| b2d934fae1 | |||
| eb72a72182 | |||
| a4b9de867c | |||
| 3a4e697414 | |||
| 00010a7508 | |||
| c5e4e97fa9 | |||
| 3f6e323b48 | |||
| b9639ec9f6 | |||
| 31bce13d16 | |||
| 3523a26abd | |||
| a6fcc9f3ff | |||
| efe0000fbe | |||
| 98a7cc66ef | |||
| 7feaf71b9e | |||
| 00a0fae7bc | |||
| 0c816c22e0 | |||
| 42f277716d | |||
| 6669b0de25 | |||
| 50fca42624 | |||
| deecb4ee9c | |||
| 762f07f450 | |||
| e02ea041b7 | |||
| 7912afb765 | |||
| 7adaa09333 | |||
| c5e7ed9aba | |||
| 68b8667998 | |||
| f643dd98e5 | |||
| dcec29dbbf | |||
| 1daff77591 | |||
| 7e3fc18c8c | |||
| b6cc5499aa | |||
| 11920b82fe | |||
| 2649504dfb | |||
| 0a7293dbbd | |||
| 057788d531 | |||
| 74cb4e2448 | |||
| 62aa79a304 | |||
| da74ae1955 | |||
| 2a4728463b | |||
| 3c5bcad0e9 | |||
| 2388353bd2 | |||
| 98823d6816 | |||
| cdd09f2535 | |||
| 2c900c59eb | |||
| 68757996de | |||
| 0fa3985b1d | |||
| a2551647b8 | |||
| e19601f991 | |||
| bc6060f98b | |||
| 0e2190fb25 | |||
| dd75a39e25 | |||
| 6efb3fffa3 | |||
| 4ef409f3cd | |||
| 0842c1cdfc | |||
| 49c045236c | |||
| 0b687df9f8 | |||
| ffcab49087 | |||
| 06c92cd328 | |||
| 66d4124439 | |||
| 99877d79e3 | |||
| 978171b600 | |||
| 4bd011702e | |||
| 64bc689bcf | |||
| 2f3f91ec82 | |||
| f6e8394771 | |||
| 27b0ba1a25 | |||
| 1070226acf | |||
| e8d7df7770 | |||
| 31f87b3a8a | |||
| 81efe6ddbf | |||
| af53865b2a | |||
| beeb8aca4c | |||
| b4063aaac9 | |||
| 7087cb2046 | |||
| 0044c43f3a | |||
| 0bb6113bfd | |||
| b6fa89c032 | |||
| 6a18e05bda | |||
| b312bd010b | |||
| 3487eaf8c5 | |||
| 9db7b3d012 | |||
| 23ecc311fd | |||
| 3355581bbf | |||
| 995707160f | |||
| b3199bac88 | |||
| 97de25d55a | |||
| 16ef7f967e | |||
| 4864a4125e | |||
| e2b71cee1f | |||
| 5bd92d47a9 | |||
| 7f0133e2ce | |||
| ba1ed66f7a | |||
| 2bc91e7a3e | |||
| 1c3a080506 | |||
| 5ecbfea028 | |||
| 9b67a24d92 | |||
| 4bd829a6a8 | |||
| 6feeba1f4f | |||
| 71b849cb58 | |||
| ba5855f5d2 | |||
| 4b04006302 | |||
| 430e03f299 | |||
| 7a2422013c | |||
| c906dc3d0c | |||
| f2fa25d449 | |||
| 0426f9beb6 | |||
| b6f0ca13f9 | |||
| 83e8f4991c | |||
| 3b38208e07 | |||
| 1a15f925a0 | |||
| 10d944eab7 | |||
| 1f873927aa | |||
| fe071ff66b | |||
| e4b79d4f3d | |||
| f6d4d0289e | |||
| 3089f3cc06 | |||
| e24f35473c | |||
| 1da605230d | |||
| fd572d83b7 | |||
| 305d4429ec | |||
| b95a3f5b2d | |||
| 4e986b181b | |||
| 65c074af9a | |||
| 58eae0b815 | |||
| c201c62b3d | |||
| 8b9b21c006 | |||
| b9c00dd82b | |||
| 910b87b847 | |||
| e37459c16b | |||
| c347afe28d | |||
| c8270fcb91 | |||
| ed399a6d14 | |||
| afa01d3d8c | |||
| ba03aaa2fa | |||
| 33f3640f66 | |||
| 46fc47bcdf | |||
| 71ec3c31fa | |||
| 2d54070cab | |||
| 67e4f04f09 | |||
| 78db1e3407 | |||
| 2368a3614d | |||
| 5053392cf2 | |||
| 6ec11460ed | |||
| 975e30c048 | |||
| 7655cb0fc6 | |||
| 7566839e9d | |||
| 7db5e82f58 | |||
| 7e67c53417 | |||
| 89fb856302 | |||
| a2fbd2b1ea | |||
| 231ed34133 | |||
| 6cff433b2e | |||
| eca83fb7b1 | |||
| 2c5adaec5c | |||
| 5d75f1c33b | |||
| d628d2314e | |||
| a9547ec349 | |||
| 2ec637df84 | |||
| 4f50ee5675 | |||
| 0faf96b983 | |||
| c3dacbc601 | |||
| 2659484000 | |||
| 6830ca75f5 | |||
| 38b4184dc3 | |||
| cfde7975d8 | |||
| d7ab696a4c | |||
| 7f7dad7f71 | |||
| 0ed21dbed7 | |||
| d2b37ee28b | |||
| b82c95e77f | |||
| baa61982a1 | |||
| 8ff6de788d | |||
| 640f82642a | |||
| 64ed269f9c | |||
| 2b58ef96eb | |||
| 74ca79ac28 | |||
| afb27bc165 | |||
| 0cbf27f44f | |||
| a5ceafa544 | |||
| cd4d669231 | |||
| cc411d06b5 | |||
| 1329f12d37 | |||
| 3899f5347b | |||
| cf02cfaa7c | |||
| e77c16ea1b | |||
| f1e2f94ee0 | |||
| 3516883b0a | |||
| c8b70b1a38 | |||
| 946625e281 | |||
| f4b7840d5c | |||
| 060f447e4a | |||
| d5bae0a2cf | |||
| f9bef804b1 | |||
| 6de03f4ed6 | |||
| e7f3e5637f | |||
| 80cefc74ec | |||
| 2f33b4b7f9 | |||
| cf52a7a509 | |||
| f5835f849a | |||
| ec5210dca8 | |||
| 422ea1a9b1 | |||
| b6f69f6b99 | |||
| a2a3819241 | |||
| 3ce33b0ac6 | |||
| e507a97d8b | |||
| 5801fdad14 | |||
| 2f4abd6a25 | |||
| 1c045ab715 | |||
| d4ca541a96 | |||
| a07a9dc6c8 | |||
| 6d60b3a23a | |||
| 37bb895b91 | |||
| f87dc917a6 | |||
| 71a15c188e | |||
| 003ecdb867 | |||
| ec7e5e5a75 | |||
| 7587f062e1 | |||
| 11970144e4 | |||
| 70750a6d79 | |||
| a53437315f | |||
| 5c73ad0310 | |||
| 4a04a271ec | |||
| 52c27bdea5 | |||
| 6fdc52c002 | |||
| e560bbc103 | |||
| b8c573685f | |||
| 3764b70b90 | |||
| 5d2de6f82b | |||
| 64d17f44fa | |||
| 6f67d44cfe | |||
| def3befb0e | |||
| 05716ae196 | |||
| c0a864297f | |||
| 04bb84cd03 | |||
| cb55accc3b | |||
| d21c227804 | |||
| 1ebccd9fa2 | |||
| cfbd0f3217 | |||
| 4afb7c0997 | |||
| 105caccc51 | |||
| 6419551117 | |||
| 585bd6616a | |||
| b8dd97cf21 | |||
| 68fc4aed78 | |||
| 7dbb259625 | |||
| 057eac7fb6 | |||
| 31c9cdf742 | |||
| 3147104132 | |||
| d6d0f37b52 | |||
| 75e48745a8 | |||
| 533417778c | |||
| e49fd4ebbd | |||
| 8412b029b1 | |||
| c65de7521f | |||
| 752c17917e | |||
| f643c7ddc6 | |||
| 6f5d4cf991 | |||
| b52466fed1 | |||
| 189534e32b | |||
| 684ae23b18 | |||
| f4d2f65602 | |||
| 65879ff37b | |||
| d902104bee | |||
| 7bad27c412 | |||
| 74a7102cf6 | |||
| e88fb03388 | |||
| 0723d8d83f | |||
| 73c9edd3e8 | |||
| 18f30bd97b | |||
| eae6e79b61 | |||
| 5bb42801d9 | |||
| 98271265d3 | |||
| 92d20477bc | |||
| 9352a0057e | |||
| 5fb874277a | |||
| d65f605398 | |||
| 7e5b448f70 | |||
| ef5da5ef36 | |||
| 410f00c4ed | |||
| 33c205dc04 | |||
| 267b3e279d | |||
| 9c1cd8093d | |||
| 201c0c2470 | |||
| 281d6e0e8b | |||
| 88746534a4 | |||
| 135f91c3c5 | |||
| 49d8dc88d9 | |||
| a7a2c1eb02 | |||
| 6596f956d2 | |||
| 9d8859833b | |||
| 65a4c10660 | |||
| 1737b50558 | |||
| 614c7006f6 | |||
| 8c901cc405 | |||
| 5d0fdfd38b | |||
| c9ed57bc56 | |||
| 0e0901993d | |||
| 54aba11091 | |||
| dc9116a7a7 | |||
| 1e90882918 | |||
| e8295e14b1 | |||
| 7ebaaf129a | |||
| ee734dede6 | |||
| ebc582c813 | |||
| 311e5a9bd2 | |||
| cd6c3c878b | |||
| 51589ec2ff | |||
| 8e1a04dc82 | |||
| 6b15f9a2ec | |||
| 8d66752556 | |||
| 266767e37d | |||
| d39775ac34 | |||
| a314f7bf64 | |||
| 37478d33eb | |||
| 5a76f3bd19 | |||
| 17e105083e | |||
| db8589b2bc | |||
| 771b016f33 | |||
| 0bc0745e8c | |||
| ea084797d3 | |||
| 2456753caf | |||
| 070de13c14 | |||
| 5e45f37ee6 | |||
| 4a96880f51 | |||
| 228ac01124 | |||
| d366027e6b | |||
| 2f35ad2a8a | |||
| 95cc9aed64 | |||
| 37d6449a49 | |||
| 249b5435d9 | |||
| 3293ebcea5 | |||
| 47d8adc77c | |||
| 356e6a691b | |||
| b26c2f3854 | |||
| 0830988687 | |||
| 456202325a | |||
| 1e47149764 | |||
| 116b63ca3a | |||
| 3096bcf8a9 | |||
| a4027029d0 | |||
| fffc9d0695 | |||
| 3ca5cf5add | |||
| 087cb77042 | |||
| 8bd1c07ec9 | |||
| 9ecb59590b | |||
| e14eb9fbc5 | |||
| 149c796227 | |||
| 3383e5b1e9 | |||
| 05862c6dc8 | |||
| b35ac41470 | |||
| 20cec56512 | |||
| 74580262b6 | |||
| f75cdae602 | |||
| 8c95f4f7ae | |||
| c3ec51c471 | |||
| 0f80a4bc18 | |||
| 0761d618f1 | |||
| 03e3c46faf | |||
| d1962b0df2 | |||
| 7a38a2303a | |||
| 6f5c2a8614 | |||
| ff36498698 | |||
| 23e19ea2e4 | |||
| c33f174041 | |||
| bbe64d74e3 | |||
| ed3a71f2ee | |||
| 46c49daba4 | |||
| a2f2ded188 | |||
| 7be061796d | |||
| 27c7d8de0c | |||
| 07542523b5 | |||
| 18597bb653 | |||
| c4be57a294 | |||
| 7ceaebb086 | |||
| 7c5ef09734 | |||
| b4d8ba66fe | |||
| 308221ce67 | |||
| 1344213335 | |||
| 7e405e9014 | |||
| b0c45132ed | |||
| 7d7738303a | |||
| dd0cdc4fc4 | |||
| 18ea40c46d | |||
| a23131efc8 | |||
| 4940a0abae | |||
| 5f98d5ae52 | |||
| ba18cded30 | |||
| fb7504e9df | |||
| 106f815a1e | |||
| 167757762b | |||
| 3a902e1a16 | |||
| 85c11672d8 | |||
| 89649df20d | |||
| 7b749b95ce | |||
| cc140be85c | |||
| e1ad765414 | |||
| 44b1fea745 | |||
| 5dd04363b2 | |||
| 03aa979309 | |||
| 6fabbb354b | |||
| f644448d0f | |||
| 4e61581cd8 | |||
| 6f87d02b72 | |||
| 348f6149b4 | |||
| a4227ef1bc | |||
| aac49a567f | |||
| 76b878b136 | |||
| 2d05931683 | |||
| b10582b0a9 | |||
| b193d951d7 | |||
| 4cd0d9dcec | |||
| 32f65b2e11 | |||
| 8c79d1e44b | |||
| 8d53f7a520 | |||
| cc83ee88fb | |||
| 0c5b02eff3 | |||
| 9da9f8fd50 | |||
| d70ffcd3e9 | |||
| 3e26d0dfe3 | |||
| eab9747b32 | |||
| 9e955d8294 | |||
| f08cd01ff8 | |||
| eabaf3b0fe | |||
| 65ca790d15 | |||
| d177944f7a | |||
| 7f186f4430 | |||
| 4f4f4642a7 | |||
| 12e443cd31 | |||
| 22a7daabe7 | |||
| c139e99abd | |||
| 2bfdb96a3f | |||
| 4b24ca924b | |||
| 1d3d714e4f | |||
| ffae6eda8a | |||
| 4dd996b728 | |||
| afad1e8dac | |||
| 8e41933251 | |||
| c581eaad53 | |||
| 3050e79d06 | |||
| 0e8ecd1065 | |||
| 94732139f4 | |||
| c5e08b2409 | |||
| c12e1b5f4a | |||
| 6cfedb55e6 | |||
| af4cb9530b | |||
| 58e97e7d5f | |||
| 2945b51617 | |||
| 9d0e2df627 | |||
| 643ae080db | |||
| a7eaa51179 | |||
| e15852ff38 | |||
| f6dec34136 | |||
| 53905fbc49 | |||
| 8218ff0fe8 | |||
| 663f7e3e6b | |||
| 4dfa2b8b88 | |||
| f828b165b1 | |||
| c56c506648 | |||
| 8e5bf2a35f | |||
| 4d575e69a4 | |||
| 4f78bbccc0 | |||
| 2d66ebe54a | |||
| a3e1209778 | |||
| 7c44a0b88d | |||
| 126058e0fa | |||
| 28742822cb | |||
| 179d370c2a | |||
| 2d8f3691cf | |||
| ce4fc9e880 | |||
| 9e357e7e5a | |||
| ed35b23e62 | |||
| 191d2d1f12 | |||
| b165d8251f | |||
| 5e8886aeb7 | |||
| bdb66635f8 | |||
| 5ba6e348da | |||
| ed52b0ce80 | |||
| 33ee3d6967 | |||
| f36676c32c | |||
| 77beddb1e7 | |||
| 1677e410b3 | |||
| 1be09347cd | |||
| c30ac2c0f3 | |||
| 145c7435a5 | |||
| 60f3b3bcc0 | |||
| 03e6d3bd30 | |||
| ee4d150e13 | |||
| 148603a10e | |||
| 1dbd933d3c | |||
| f7ee7423fe | |||
| 6322f1e37a | |||
| 0d8c7fbb9d | |||
| 70e30b02a4 | |||
| ebd21ea9b2 | |||
| 9aa092cd34 | |||
| b274fe85b7 | |||
| 777c36998c | |||
| a3977428f9 | |||
| 2d626c263c | |||
| d1461f2e68 | |||
| 3b778d2cc7 | |||
| 67b7d17a2f | |||
| 1afeadc342 | |||
| f6aa4e2092 | |||
| 3b00c5bb96 | |||
| ef7eed579b | |||
| 568a0085fe |
@@ -24,6 +24,7 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Cache and install APT packages
|
||||
description: >-
|
||||
Wraps awalsh128/cache-apt-pkgs-action with the workarounds Home Assistant CI
|
||||
needs. Removes the conflicting Microsoft apt source before any apt run, and
|
||||
points the dynamic linker at the host's multiarch lib subdirectories so
|
||||
shared libraries that rely on update-alternatives or postinst-managed paths
|
||||
(eg libblas, liblapack pulled in by ffmpeg) stay reachable since the upstream
|
||||
action does not execute postinst scripts on cache restore.
|
||||
|
||||
inputs:
|
||||
packages:
|
||||
description: Space-delimited list of apt packages to install.
|
||||
required: true
|
||||
version:
|
||||
description: Cache version. Bump to invalidate the cache.
|
||||
required: false
|
||||
default: "1"
|
||||
execute_install_scripts:
|
||||
description: >-
|
||||
Pass-through to awalsh128/cache-apt-pkgs-action. Postinst scripts are not
|
||||
actually cached by the upstream action, so this is largely a no-op today.
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Remove conflicting Microsoft apt source
|
||||
shell: bash
|
||||
run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
|
||||
- name: Install apt packages via cache
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
|
||||
with:
|
||||
packages: ${{ inputs.packages }}
|
||||
version: ${{ inputs.version }}
|
||||
execute_install_scripts: ${{ inputs.execute_install_scripts }}
|
||||
- name: Refresh dynamic linker cache
|
||||
shell: bash
|
||||
run: |
|
||||
# awalsh128/cache-apt-pkgs-action does not run postinst scripts on
|
||||
# cache restore, so update-alternatives symlinks (eg the one libblas
|
||||
# creates at /usr/lib/<multiarch>/libblas.so.3) are never produced.
|
||||
# Add every /usr/lib/<multiarch> subdirectory that holds shared
|
||||
# libraries to the ldconfig search path so the dynamic linker still
|
||||
# finds them. Use dpkg-architecture to derive the host's multiarch
|
||||
# tuple so this works on non-x86_64 runners too.
|
||||
multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
|
||||
find "/usr/lib/${multiarch}" -mindepth 2 -maxdepth 2 \
|
||||
-name '*.so.*' -printf '%h\n' \
|
||||
| sort -u \
|
||||
| sudo tee /etc/ld.so.conf.d/zzz-cache-apt-extras.conf > /dev/null
|
||||
sudo ldconfig
|
||||
@@ -43,6 +43,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
- 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. 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.
|
||||
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -530,7 +530,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -543,7 +543,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
+7
-7
@@ -36,7 +36,7 @@
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
# - github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -961,7 +961,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1325,7 +1325,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1383,7 +1383,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
+101
-208
@@ -39,7 +39,7 @@ on:
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.6"
|
||||
HA_SHORT_VERSION: "2026.7"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
@@ -60,9 +60,7 @@ env:
|
||||
# - 15.2 is the latest (as of 9 Feb 2023)
|
||||
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
|
||||
UV_CACHE_DIR: /tmp/uv-cache
|
||||
APT_CACHE_BASE: /home/runner/work/apt
|
||||
APT_CACHE_DIR: /home/runner/work/apt/cache
|
||||
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
|
||||
APT_CACHE_VERSION: 1
|
||||
SQLALCHEMY_WARN_20: 1
|
||||
PYTHONASYNCIODEBUG: 1
|
||||
HASS_CI: 1
|
||||
@@ -86,7 +84,6 @@ jobs:
|
||||
core: ${{ steps.core.outputs.changes }}
|
||||
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
|
||||
integrations: ${{ steps.integrations.outputs.changes }}
|
||||
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
|
||||
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
|
||||
requirements: ${{ steps.core.outputs.requirements }}
|
||||
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
|
||||
@@ -116,10 +113,6 @@ jobs:
|
||||
# Include HA_SHORT_VERSION to force the immediate creation
|
||||
# of a new uv cache entry after a version bump.
|
||||
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
|
||||
- name: Generate partial apt restore key
|
||||
id: generate_apt_cache_key
|
||||
run: |
|
||||
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
|
||||
- name: Filter for core changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: core
|
||||
@@ -384,65 +377,36 @@ jobs:
|
||||
path: ${{ env.UV_CACHE_DIR }}
|
||||
key: ${{ steps.generate-uv-key.outputs.full_key }}
|
||||
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
|
||||
- name: Check if apt cache exists
|
||||
id: cache-apt-check
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
if: |
|
||||
steps.cache-venv.outputs.cache-hit != 'true'
|
||||
|| steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
id: install-os-deps
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
|
||||
mkdir -p ${APT_CACHE_DIR}
|
||||
mkdir -p ${APT_LIST_CACHE_DIR}
|
||||
fi
|
||||
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils \
|
||||
libavcodec-dev \
|
||||
libavdevice-dev \
|
||||
libavfilter-dev \
|
||||
libavformat-dev \
|
||||
libavutil-dev \
|
||||
libswresample-dev \
|
||||
libswscale-dev \
|
||||
libudev-dev
|
||||
|
||||
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
|
||||
sudo chmod -R 755 ${APT_CACHE_BASE}
|
||||
fi
|
||||
- name: Save apt cache
|
||||
if: |
|
||||
always()
|
||||
&& steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
&& steps.install-os-deps.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
libavcodec-dev
|
||||
libavdevice-dev
|
||||
libavfilter-dev
|
||||
libavformat-dev
|
||||
libavutil-dev
|
||||
libswresample-dev
|
||||
libswscale-dev
|
||||
libudev-dev
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Read uv version from requirements.txt
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
id: read-uv-version
|
||||
run: |
|
||||
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
|
||||
- name: Set up uv
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: ${{ steps.read-uv-version.outputs.version }}
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
id: create-venv
|
||||
@@ -450,8 +414,6 @@ jobs:
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pip install "$(grep '^uv' < requirements.txt)"
|
||||
uv pip install -U "pip>=25.2"
|
||||
uv pip install -r requirements.txt
|
||||
uv pip install -r requirements_all.txt -r requirements_test.txt
|
||||
uv pip install -e . --config-settings editable_mode=compat
|
||||
@@ -506,30 +468,16 @@ jobs:
|
||||
&& github.event.inputs.mypy-only != 'true'
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
libturbojpeg
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: libturbojpeg
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -876,32 +824,20 @@ jobs:
|
||||
- info
|
||||
- base
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -952,33 +888,21 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -1105,34 +1029,22 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libmariadb-dev-compat \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libmariadb-dev-compat
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -1266,36 +1178,29 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
sudo apt-get -y install \
|
||||
postgresql-server-dev-14
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up PostgreSQL apt repository
|
||||
run: sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
- name: Cache PostgreSQL development headers
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: postgresql-server-dev-14
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -1421,7 +1326,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1449,33 +1354,21 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -1592,7 +1485,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
@@ -1620,7 +1513,7 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
report_type: test_results
|
||||
fail_ci_if_error: true
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write # To lock issues
|
||||
pull-requests: write # To lock pull requests
|
||||
steps:
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
- uses: dessant/lock-threads@851cffe46851ddd2051ea7147ebdc995113241c3 # v6.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
@@ -55,11 +55,11 @@ jobs:
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v1.7.0
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.13
|
||||
rev: v0.15.14
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -337,6 +337,7 @@ homeassistant.components.led_ble.*
|
||||
homeassistant.components.lektrico.*
|
||||
homeassistant.components.letpot.*
|
||||
homeassistant.components.lg_infrared.*
|
||||
homeassistant.components.lg_tv_rs232.*
|
||||
homeassistant.components.libre_hardware_monitor.*
|
||||
homeassistant.components.lidarr.*
|
||||
homeassistant.components.liebherr.*
|
||||
@@ -428,6 +429,7 @@ homeassistant.components.otp.*
|
||||
homeassistant.components.ouman_eh_800.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.overseerr.*
|
||||
homeassistant.components.ovhcloud_ai_endpoints.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
homeassistant.components.paj_gps.*
|
||||
homeassistant.components.panel_custom.*
|
||||
@@ -565,6 +567,7 @@ homeassistant.components.technove.*
|
||||
homeassistant.components.tedee.*
|
||||
homeassistant.components.telegram_bot.*
|
||||
homeassistant.components.teleinfo.*
|
||||
homeassistant.components.teltonika.*
|
||||
homeassistant.components.teslemetry.*
|
||||
homeassistant.components.text.*
|
||||
homeassistant.components.thethingsnetwork.*
|
||||
@@ -609,6 +612,7 @@ homeassistant.components.valve.*
|
||||
homeassistant.components.velbus.*
|
||||
homeassistant.components.velux.*
|
||||
homeassistant.components.victron_gx.*
|
||||
homeassistant.components.vistapool.*
|
||||
homeassistant.components.vivotek.*
|
||||
homeassistant.components.vlc_telnet.*
|
||||
homeassistant.components.vodafone_station.*
|
||||
|
||||
@@ -33,6 +33,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
- 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. 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.
|
||||
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
Generated
+12
-2
@@ -987,6 +987,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/tests/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/homeassistant/components/lg_tv_rs232/ @balloob
|
||||
/tests/components/lg_tv_rs232/ @balloob
|
||||
/homeassistant/components/libre_hardware_monitor/ @Sab44
|
||||
/tests/components/libre_hardware_monitor/ @Sab44
|
||||
/homeassistant/components/lichess/ @aryanhasgithub
|
||||
@@ -1290,6 +1292,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/openhome/ @bazwilliams
|
||||
/homeassistant/components/openrgb/ @felipecrs
|
||||
/tests/components/openrgb/ @felipecrs
|
||||
/homeassistant/components/opensensemap/ @AlCalzone
|
||||
/tests/components/opensensemap/ @AlCalzone
|
||||
/homeassistant/components/opensky/ @joostlek
|
||||
/tests/components/opensky/ @joostlek
|
||||
/homeassistant/components/opentherm_gw/ @mvn23
|
||||
@@ -1317,6 +1321,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/overkiz/ @imicknl
|
||||
/homeassistant/components/overseerr/ @joostlek @AmGarera
|
||||
/tests/components/overseerr/ @joostlek @AmGarera
|
||||
/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon
|
||||
/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon
|
||||
/homeassistant/components/ovo_energy/ @timmo001
|
||||
/tests/components/ovo_energy/ @timmo001
|
||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||
@@ -1930,6 +1936,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/victron_remote_monitoring/ @AndyTempel
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
/tests/components/vilfo/ @ManneW
|
||||
/homeassistant/components/vistapool/ @fdebrus
|
||||
/tests/components/vistapool/ @fdebrus
|
||||
/homeassistant/components/vivotek/ @HarlemSquirrel
|
||||
/tests/components/vivotek/ @HarlemSquirrel
|
||||
/homeassistant/components/vizio/ @raman325
|
||||
@@ -2046,14 +2054,16 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/yamaha_musiccast/ @vigonotion @micha91
|
||||
/homeassistant/components/yandex_transport/ @rishatik92 @devbis
|
||||
/tests/components/yandex_transport/ @rishatik92 @devbis
|
||||
/homeassistant/components/yardian/ @h3l1o5
|
||||
/tests/components/yardian/ @h3l1o5
|
||||
/homeassistant/components/yardian/ @aeon-matrix
|
||||
/tests/components/yardian/ @aeon-matrix
|
||||
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
||||
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
||||
/homeassistant/components/yeelightsunflower/ @lindsaymarkward
|
||||
/homeassistant/components/yi/ @bachya
|
||||
/homeassistant/components/yolink/ @matrixd2
|
||||
/tests/components/yolink/ @matrixd2
|
||||
/homeassistant/components/yoto/ @cdnninja @piitaya
|
||||
/tests/components/yoto/ @cdnninja @piitaya
|
||||
/homeassistant/components/youless/ @gjong
|
||||
/tests/components/youless/ @gjong
|
||||
/homeassistant/components/youtube/ @joostlek
|
||||
|
||||
@@ -92,8 +92,7 @@ def _extract_backup(
|
||||
):
|
||||
ostf.tar.extractall(
|
||||
path=Path(tempdir, "extracted"),
|
||||
members=securetar.secure_path(ostf.tar),
|
||||
filter="fully_trusted",
|
||||
filter="tar",
|
||||
)
|
||||
backup_meta_file = Path(tempdir, "extracted", "backup.json")
|
||||
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
|
||||
@@ -119,8 +118,7 @@ def _extract_backup(
|
||||
) as istf:
|
||||
istf.extractall(
|
||||
path=Path(tempdir, "homeassistant"),
|
||||
members=securetar.secure_path(istf),
|
||||
filter="fully_trusted",
|
||||
filter="tar",
|
||||
)
|
||||
if restore_content.restore_homeassistant:
|
||||
keep = list(KEEP_BACKUPS)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"lg_netcast",
|
||||
"lg_soundbar",
|
||||
"lg_thinq",
|
||||
"lg_tv_rs232",
|
||||
"webostv"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.components.select import (
|
||||
PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA,
|
||||
SelectEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import CONF_NAME, CONF_OPTIONS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -19,9 +19,6 @@ from .hub import AdsHub
|
||||
|
||||
DEFAULT_NAME = "ADS select"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_OPTIONS = "options"
|
||||
|
||||
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ADS_VAR): cv.string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""Alexa Devices integration."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
from homeassistant.const import CONF_COUNTRY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
|
||||
|
||||
from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
@@ -12,6 +16,8 @@ from .services import async_setup_services
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.EVENT,
|
||||
Platform.MEDIA_PLAYER,
|
||||
Platform.NOTIFY,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
@@ -34,6 +40,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
await coordinator.sync_history_state()
|
||||
await coordinator.sync_media_state()
|
||||
|
||||
async def _on_http2_reauth_required() -> None:
|
||||
entry.async_start_reauth(hass)
|
||||
|
||||
async def _cancel_http2() -> None:
|
||||
http2_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await http2_task
|
||||
|
||||
alexa_httpx_client = httpx_client.get_async_client(
|
||||
hass,
|
||||
alpn_protocols=SSL_ALPN_HTTP11_HTTP2,
|
||||
)
|
||||
|
||||
http2_task = await coordinator.api.start_http2_processing(
|
||||
alexa_httpx_client, on_reauth_required=_on_http2_reauth_required
|
||||
)
|
||||
|
||||
entry.async_on_unload(_cancel_http2)
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -8,13 +8,18 @@ from aioamazondevices.exceptions import (
|
||||
CannotConnect,
|
||||
CannotRetrieveData,
|
||||
)
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
from aioamazondevices.structures import (
|
||||
AmazonDevice,
|
||||
AmazonMediaState,
|
||||
AmazonVocalRecord,
|
||||
AmazonVolumeState,
|
||||
)
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -73,6 +78,18 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
if routine.domain == Platform.BUTTON
|
||||
}
|
||||
|
||||
self._vocal_records: dict[str, AmazonVocalRecord] = {}
|
||||
self.api.on_history_event.append(self.history_state_event_handler)
|
||||
self.api.on_history_event.freeze()
|
||||
|
||||
self._volume_states: dict[str, AmazonVolumeState] = {}
|
||||
self.api.on_volume_state_event.append(self.volume_state_event_handler)
|
||||
self.api.on_volume_state_event.freeze()
|
||||
|
||||
self._media_states: dict[str, AmazonMediaState] = {}
|
||||
self.api.on_media_state_event.append(self.media_state_event_handler)
|
||||
self.api.on_media_state_event.freeze()
|
||||
|
||||
async def _async_update_data(self) -> dict[str, AmazonDevice]:
|
||||
"""Update device data."""
|
||||
try:
|
||||
@@ -149,3 +166,66 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
)
|
||||
if entity_id:
|
||||
entity_registry.async_remove(entity_id)
|
||||
|
||||
async def sync_history_state(self) -> None:
|
||||
"""Sync history state."""
|
||||
try:
|
||||
self._vocal_records = await self.api.sync_history_state()
|
||||
except CannotAuthenticate as e:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(e)},
|
||||
) from e
|
||||
except CannotConnect as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(e)},
|
||||
) from e
|
||||
except BaseException as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(e)},
|
||||
) from e
|
||||
|
||||
async def history_state_event_handler(
|
||||
self, vocal_records: dict[str, AmazonVocalRecord]
|
||||
) -> None:
|
||||
"""Handle pushed vocal record events."""
|
||||
self._vocal_records = {**self._vocal_records, **vocal_records}
|
||||
self.async_update_listeners()
|
||||
|
||||
@property
|
||||
def vocal_records(self) -> dict[str, AmazonVocalRecord]:
|
||||
"""Vocal records of devices."""
|
||||
return self._vocal_records
|
||||
|
||||
async def sync_media_state(self) -> None:
|
||||
"""Sync media state."""
|
||||
await self.api.sync_media_state()
|
||||
|
||||
async def media_state_event_handler(
|
||||
self, media_state: dict[str, AmazonMediaState]
|
||||
) -> None:
|
||||
"""Handle pushed media state changed events."""
|
||||
self._media_states = media_state
|
||||
self.async_update_listeners()
|
||||
|
||||
@property
|
||||
def media_states(self) -> dict[str, AmazonMediaState]:
|
||||
"""Media state of devices."""
|
||||
return self._media_states
|
||||
|
||||
async def volume_state_event_handler(
|
||||
self, volume_states: dict[str, AmazonVolumeState]
|
||||
) -> None:
|
||||
"""Handle pushed volume change events."""
|
||||
self._volume_states = volume_states
|
||||
self.async_update_listeners()
|
||||
|
||||
@property
|
||||
def volume_states(self) -> dict[str, AmazonVolumeState]:
|
||||
"""Volumes of devices."""
|
||||
return self._volume_states
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Support for events."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.event import EventEntity, EventEntityDescription
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import _LOGGER
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .entity import AmazonEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
EVENTS: Final = {
|
||||
EventEntityDescription(
|
||||
key="voice_event",
|
||||
translation_key="voice_event",
|
||||
),
|
||||
}
|
||||
|
||||
EVENT_TYPE = "triggered"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Alexa Devices events based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AlexaVoiceEvent(coordinator, serial_num, event_desc)
|
||||
for event_desc in EVENTS
|
||||
for serial_num in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class AlexaVoiceEvent(AmazonEntity, EventEntity):
|
||||
"""Representation of an Alexa voice event."""
|
||||
|
||||
_attr_event_types = [EVENT_TYPE]
|
||||
coordinator: AmazonDevicesCoordinator
|
||||
_last_seen_timestamp: int | None = None
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
|
||||
if not (
|
||||
vocal_record := self.coordinator.vocal_records.get(
|
||||
self.device.serial_number
|
||||
)
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"No vocal record found for device %s [%s]",
|
||||
self.device.account_name,
|
||||
self.device.serial_number,
|
||||
)
|
||||
return
|
||||
|
||||
if vocal_record.timestamp == self._last_seen_timestamp:
|
||||
return
|
||||
|
||||
self._last_seen_timestamp = vocal_record.timestamp
|
||||
self._trigger_event(
|
||||
EVENT_TYPE,
|
||||
{
|
||||
"intent": vocal_record.intent,
|
||||
"voice_command": vocal_record.title,
|
||||
"voice_reply": vocal_record.sub_title,
|
||||
},
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entity": {
|
||||
"event": {
|
||||
"voice_event": {
|
||||
"default": "mdi:chat-processing"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"voc_index": {
|
||||
"default": "mdi:molecule"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.7.0"]
|
||||
"requirements": ["aioamazondevices==13.8.1"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Media player platform for Alexa Devices."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Final
|
||||
|
||||
from aioamazondevices.structures import (
|
||||
AmazonMediaControls,
|
||||
AmazonMediaState,
|
||||
AmazonVolumeState,
|
||||
)
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEnqueue,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityDescription,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import _LOGGER
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .entity import AmazonEntity
|
||||
from .utils import alexa_api_call
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
STANDARD_SUPPORTED_FEATURES = (
|
||||
MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
|
||||
"""Describes an Alexa Devices media player entity."""
|
||||
|
||||
|
||||
MEDIA_PLAYERS: Final = (
|
||||
AmazonDevicesMediaPlayerEntityDescription(
|
||||
key="media",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Alexa Devices media player entities from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
"""Add entities for newly discovered devices."""
|
||||
new_entities: list[AlexaDevicesMediaPlayer] = []
|
||||
|
||||
for serial_num, device in coordinator.data.items():
|
||||
if serial_num in known_devices or not device.media_player_supported:
|
||||
continue
|
||||
|
||||
known_devices.add(serial_num)
|
||||
new_entities.extend(
|
||||
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
|
||||
for description in MEDIA_PLAYERS
|
||||
)
|
||||
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
remove_listener = coordinator.async_add_listener(_check_device)
|
||||
entry.async_on_unload(remove_listener)
|
||||
_check_device()
|
||||
|
||||
|
||||
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
"""Representation of an Alexa device media player."""
|
||||
|
||||
entity_description: AmazonDevicesMediaPlayerEntityDescription
|
||||
|
||||
_attr_name = None # Uses the device name
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_volume_step = 0.05
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
serial_num: str,
|
||||
description: AmazonDevicesMediaPlayerEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self._prev_volume: int | None = None
|
||||
super().__init__(coordinator, serial_num, description)
|
||||
|
||||
@property
|
||||
def media_state(self) -> AmazonMediaState | None:
|
||||
"""Return the media state relating to device."""
|
||||
if not self.coordinator or not self.coordinator.media_states:
|
||||
return None
|
||||
return self.coordinator.media_states.get(self._serial_num)
|
||||
|
||||
@property
|
||||
def volume_state(self) -> AmazonVolumeState | None:
|
||||
"""Volume settings for device."""
|
||||
if not self.coordinator or not self.coordinator.volume_states:
|
||||
return None
|
||||
return self.coordinator.volume_states.get(self._serial_num)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
"""Return dynamically supported features based on current media."""
|
||||
features = STANDARD_SUPPORTED_FEATURES
|
||||
|
||||
if self.media_state is None:
|
||||
return features
|
||||
|
||||
if self.media_state.pause_enabled:
|
||||
features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE
|
||||
|
||||
if self.media_state.next_enabled:
|
||||
features |= MediaPlayerEntityFeature.NEXT_TRACK
|
||||
|
||||
if self.media_state.previous_enabled:
|
||||
features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
|
||||
return features
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState | None:
|
||||
"""Return the current state of the player."""
|
||||
if not self.media_state:
|
||||
return MediaPlayerState.IDLE
|
||||
if self.media_state.player_state == "PLAYING":
|
||||
return MediaPlayerState.PLAYING
|
||||
if self.media_state.player_state == "PAUSED":
|
||||
return MediaPlayerState.PAUSED
|
||||
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
"""Return the volume level (0.0 to 1.0)."""
|
||||
if not self.volume_state or self.volume_state.volume is None:
|
||||
return None
|
||||
return self.volume_state.volume / 100
|
||||
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return True if the volume is muted."""
|
||||
if not self.volume_state:
|
||||
return None
|
||||
return self.volume_state.volume == 0
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
"""Track title."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.now_playing_title
|
||||
|
||||
@property
|
||||
def media_artist(self) -> str | None:
|
||||
"""Artist name."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.now_playing_line1
|
||||
|
||||
@property
|
||||
def media_album_name(self) -> str | None:
|
||||
"""Album name."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.now_playing_line2
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Album art URL."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.now_playing_url
|
||||
|
||||
@property
|
||||
def media_duration(self) -> int | None:
|
||||
"""Duration in seconds."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.media_length
|
||||
|
||||
@property
|
||||
def media_position(self) -> int | None:
|
||||
"""Current playback position in seconds."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.media_position
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self) -> datetime | None:
|
||||
"""When media_position was last updated — HA uses this to interpolate the progress bar."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.media_position_updated_at
|
||||
|
||||
@property
|
||||
def media_content_type(self) -> MediaType | None:
|
||||
"""Content type — tells HA what kind of media is playing."""
|
||||
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
|
||||
return MediaType.MUSIC
|
||||
return None
|
||||
|
||||
async def async_play_media(
|
||||
self,
|
||||
media_type: MediaType | str,
|
||||
media_id: str,
|
||||
enqueue: MediaPlayerEnqueue | None = None,
|
||||
announce: bool | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Play a piece of media."""
|
||||
await self.async_call_alexa_music(media_id, media_type)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_call_alexa_music(
|
||||
self, search_phrase: str, provider_id: str
|
||||
) -> None:
|
||||
"""Call alexa music."""
|
||||
await self.coordinator.api.call_alexa_music(
|
||||
self.device, search_phrase, provider_id
|
||||
)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_set_device_volume(self, volume: int) -> None:
|
||||
"""Set the device volume."""
|
||||
_LOGGER.debug(
|
||||
"Setting volume for %s to %s%%",
|
||||
self.device.serial_number,
|
||||
volume,
|
||||
)
|
||||
await self.coordinator.api.set_device_volume(self.device, volume)
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set the volume level (0.0 to 1.0)."""
|
||||
device_volume = round(volume * 100)
|
||||
await self.async_set_device_volume(device_volume)
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute or un-mute the volume."""
|
||||
# Whilst you can mute a device by asking it there appears to be
|
||||
# no way to do this programmatically so set volume to 0
|
||||
if not self.volume_state or self.volume_state.volume is None:
|
||||
return
|
||||
if mute:
|
||||
self._prev_volume = self.volume_state.volume
|
||||
target_volume = 0
|
||||
else:
|
||||
if self._prev_volume is None:
|
||||
return
|
||||
target_volume = self._prev_volume
|
||||
await self.async_set_volume_level(target_volume / 100)
|
||||
|
||||
@alexa_api_call
|
||||
async def _send_media_command(self, command: AmazonMediaControls) -> None:
|
||||
_LOGGER.debug(
|
||||
"Sending media command '%s' to %s", command, self.device.serial_number
|
||||
)
|
||||
await self.coordinator.api.send_media_command(self.device, command)
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
await self._send_media_command(AmazonMediaControls.Stop)
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
await self._send_media_command(AmazonMediaControls.Pause)
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
await self._send_media_command(AmazonMediaControls.Play)
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
await self._send_media_command(AmazonMediaControls.Next)
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
await self._send_media_command(AmazonMediaControls.Previous)
|
||||
@@ -58,6 +58,18 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"event": {
|
||||
"voice_event": {
|
||||
"name": "Voice event",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"triggered": "Triggered"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"announce": {
|
||||
"name": "Announce"
|
||||
|
||||
@@ -7,10 +7,11 @@ from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import CONF_HOST, DOMAIN
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
"""Constants for the Altruist integration."""
|
||||
|
||||
DOMAIN = "altruist"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_HOST = "host"
|
||||
|
||||
@@ -10,13 +10,12 @@ import logging
|
||||
from altruistclient import AltruistClient, AltruistError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_HOST
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
@@ -5,8 +5,12 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.components.hassio import HassioNotReadyError
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
@@ -49,6 +53,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
|
||||
|
||||
LABS_SNAPSHOT_FEATURE = "snapshots"
|
||||
|
||||
@@ -57,18 +62,39 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the analytics integration."""
|
||||
analytics_config = config.get(DOMAIN, {})
|
||||
|
||||
snapshots_url: str | None = None
|
||||
if CONF_SNAPSHOTS_URL in analytics_config:
|
||||
await labs.async_update_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
|
||||
)
|
||||
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
|
||||
else:
|
||||
snapshots_url = None
|
||||
|
||||
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
|
||||
|
||||
discovery_flow.async_create_flow(
|
||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Analytics from a config entry."""
|
||||
snapshots_url = hass.data[_DATA_SNAPSHOTS_URL]
|
||||
analytics = Analytics(hass, snapshots_url)
|
||||
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
try:
|
||||
await analytics.load()
|
||||
except HassioNotReadyError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_not_ready",
|
||||
) from err
|
||||
|
||||
started = False
|
||||
|
||||
@@ -80,8 +106,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if started:
|
||||
await analytics.async_schedule()
|
||||
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
async def start_schedule(hass: HomeAssistant) -> None:
|
||||
"""Start the send schedule once Home Assistant has started."""
|
||||
nonlocal started
|
||||
started = True
|
||||
await analytics.async_schedule()
|
||||
@@ -89,12 +115,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
async_at_started(hass, start_schedule)
|
||||
|
||||
hass.data[DATA_COMPONENT] = analytics
|
||||
return True
|
||||
@@ -109,7 +130,9 @@ def websocket_analytics(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return analytics preferences."""
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
|
||||
@@ -130,8 +153,10 @@ async def websocket_analytics_preferences(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update analytics preferences."""
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
preferences = msg[ATTR_PREFERENCES]
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
|
||||
await analytics.save_preferences(preferences)
|
||||
await analytics.async_schedule()
|
||||
|
||||
@@ -299,12 +299,8 @@ class Analytics:
|
||||
self._data = AnalyticsData.from_dict(stored)
|
||||
|
||||
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.
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable.
|
||||
# The caller is responsible for handling this and triggering a retry.
|
||||
supervisor_info = hassio.get_supervisor_info(self._hass)
|
||||
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
@@ -349,10 +345,10 @@ 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)
|
||||
# Try to pull Supervisor information, but don't fail if some or all
|
||||
# of it is unavailable due to setup failures in the hassio integration.
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
operating_system_info = hassio.get_os_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Config flow for Analytics integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Analytics."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_system(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
return self.async_create_entry(title="Analytics", data={})
|
||||
@@ -14,5 +14,6 @@
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal"
|
||||
"quality_scale": "internal",
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"supervisor_not_ready": {
|
||||
"message": "Supervisor was not ready during setup, will retry"
|
||||
}
|
||||
},
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
|
||||
@@ -230,13 +230,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 3:
|
||||
# Remove Temperature parameter
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
temperature_key = "temperature"
|
||||
|
||||
for subentry in entry.subentries.values():
|
||||
data = subentry.data.copy()
|
||||
if CONF_TEMPERATURE not in data:
|
||||
if temperature_key not in data:
|
||||
continue
|
||||
data.pop(CONF_TEMPERATURE, None)
|
||||
data.pop(temperature_key, None)
|
||||
hass.config_entries.async_update_subentry(entry, subentry, data=data)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=4)
|
||||
|
||||
@@ -7,27 +7,3 @@ CONNECTION_TIMEOUT: int = 10
|
||||
|
||||
# Field name of last self test retrieved from apcupsd.
|
||||
LAST_S_TEST: Final = "laststest"
|
||||
|
||||
# Mapping of deprecated sensor keys (as reported by apcupsd,
|
||||
# lower-cased) to their deprecation
|
||||
# repair issue translation keys.
|
||||
DEPRECATED_SENSORS: Final = {
|
||||
"apc": "apc_deprecated",
|
||||
"end apc": "date_deprecated",
|
||||
"date": "date_deprecated",
|
||||
"apcmodel": "available_via_device_info",
|
||||
"model": "available_via_device_info",
|
||||
"firmware": "available_via_device_info",
|
||||
"version": "available_via_device_info",
|
||||
"upsname": "available_via_device_info",
|
||||
"serialno": "available_via_device_info",
|
||||
}
|
||||
|
||||
AVAILABLE_VIA_DEVICE_ATTR: Final = {
|
||||
"apcmodel": "model",
|
||||
"model": "model",
|
||||
"firmware": "hw_version",
|
||||
"version": "sw_version",
|
||||
"upsname": "name",
|
||||
"serialno": "serial_number",
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Support for APCUPSd sensors."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
import dateutil
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -24,11 +23,9 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
import homeassistant.helpers.issue_registry as ir
|
||||
|
||||
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST
|
||||
from .const import LAST_S_TEST
|
||||
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
|
||||
from .entity import APCUPSdEntity
|
||||
|
||||
@@ -36,6 +33,20 @@ PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# List of useless sensors to ignore, since they are either provided in device
|
||||
# information, or not useful at all
|
||||
IGNORED_SENSORS: Final = {
|
||||
"apc",
|
||||
"end apc",
|
||||
"date",
|
||||
"apcmodel",
|
||||
"model",
|
||||
"firmware",
|
||||
"version",
|
||||
"upsname",
|
||||
"serialno",
|
||||
}
|
||||
|
||||
SENSORS: dict[str, SensorEntityDescription] = {
|
||||
"alarmdel": SensorEntityDescription(
|
||||
key="alarmdel",
|
||||
@@ -49,18 +60,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"apc": SensorEntityDescription(
|
||||
key="apc",
|
||||
translation_key="apc_status",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"apcmodel": SensorEntityDescription(
|
||||
key="apcmodel",
|
||||
translation_key="apc_model",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"badbatts": SensorEntityDescription(
|
||||
key="badbatts",
|
||||
translation_key="bad_batteries",
|
||||
@@ -100,12 +99,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
),
|
||||
"date": SensorEntityDescription(
|
||||
key="date",
|
||||
translation_key="date",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"dipsw": SensorEntityDescription(
|
||||
key="dipsw",
|
||||
translation_key="dip_switch_settings",
|
||||
@@ -132,23 +125,11 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="wake_delay",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"end apc": SensorEntityDescription(
|
||||
key="end apc",
|
||||
translation_key="date_and_time",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"extbatts": SensorEntityDescription(
|
||||
key="extbatts",
|
||||
translation_key="external_batteries",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"firmware": SensorEntityDescription(
|
||||
key="firmware",
|
||||
translation_key="firmware_version",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"hitrans": SensorEntityDescription(
|
||||
key="hitrans",
|
||||
translation_key="transfer_high",
|
||||
@@ -264,12 +245,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="min_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"model": SensorEntityDescription(
|
||||
key="model",
|
||||
translation_key="model",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"nombattv": SensorEntityDescription(
|
||||
key="nombattv",
|
||||
translation_key="battery_nominal_voltage",
|
||||
@@ -358,12 +333,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"serialno": SensorEntityDescription(
|
||||
key="serialno",
|
||||
translation_key="serial_number",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"starttime": SensorEntityDescription(
|
||||
key="starttime",
|
||||
translation_key="startup_time",
|
||||
@@ -404,18 +373,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="ups_mode",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"upsname": SensorEntityDescription(
|
||||
key="upsname",
|
||||
translation_key="ups_name",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"version": SensorEntityDescription(
|
||||
key="version",
|
||||
translation_key="version",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"xoffbat": SensorEntityDescription(
|
||||
key="xoffbat",
|
||||
translation_key="transfer_from_battery",
|
||||
@@ -481,9 +438,10 @@ async def async_setup_entry(
|
||||
# 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.
|
||||
# created is deterministic
|
||||
for resource in sorted(available_resources | {LAST_S_TEST}):
|
||||
if resource in IGNORED_SENSORS:
|
||||
continue
|
||||
if resource not in SENSORS:
|
||||
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
||||
continue
|
||||
@@ -561,63 +519,3 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
self._attr_native_value, inferred_unit = infer_unit(data)
|
||||
if not self.native_unit_of_measurement:
|
||||
self._attr_native_unit_of_measurement = inferred_unit
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle when entity is added to Home Assistant.
|
||||
|
||||
If this is a deprecated sensor entity, create a repair issue to guide
|
||||
the user to disable it.
|
||||
"""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
reason = DEPRECATED_SENSORS.get(self.entity_description.key)
|
||||
if not reason:
|
||||
return
|
||||
|
||||
automations = automations_with_entity(self.hass, self.entity_id)
|
||||
scripts = scripts_with_entity(self.hass, self.entity_id)
|
||||
if not automations and not scripts:
|
||||
return
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
items = [
|
||||
f"- [{entry.name or entry.original_name or entity_id}]"
|
||||
f"(/config/{integration}/edit/"
|
||||
f"{entry.unique_id or entity_id.split('.', 1)[-1]})"
|
||||
for integration, entities in (
|
||||
("automation", automations),
|
||||
("script", scripts),
|
||||
)
|
||||
for entity_id in entities
|
||||
if (entry := entity_registry.async_get(entity_id))
|
||||
]
|
||||
placeholders = {
|
||||
"entity_name": str(self.name or self.entity_id),
|
||||
"entity_id": self.entity_id,
|
||||
"items": "\n".join(items),
|
||||
}
|
||||
if via_attr := AVAILABLE_VIA_DEVICE_ATTR.get(self.entity_description.key):
|
||||
placeholders["available_via_device_attr"] = via_attr
|
||||
if device_entry := self.device_entry:
|
||||
placeholders["device_id"] = device_entry.id
|
||||
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"{reason}_{self.entity_id}",
|
||||
breaks_in_ha_version="2026.6.0",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=reason,
|
||||
translation_placeholders=placeholders,
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Handle when entity will be removed from Home Assistant."""
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
if issue_key := DEPRECATED_SENSORS.get(self.entity_description.key):
|
||||
ir.async_delete_issue(self.hass, DOMAIN, f"{issue_key}_{self.entity_id}")
|
||||
|
||||
@@ -241,19 +241,5 @@
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to APC UPS Daemon."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"apc_deprecated": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because it exposes internal details of the APC UPS Daemon response.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use supported APC UPS entities instead. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
},
|
||||
"available_via_device_info": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the same value is available from the device registry via `device_attr(\"{device_id}\", \"{available_via_device_attr}\")`.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use the `device_attr` helper instead of this sensor. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
},
|
||||
"date_deprecated": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the timestamp is already available from other APC UPS sensors via their last updated time.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to reference any entity's `last_updated` attribute instead (for example, `states.binary_sensor.apcups_online_status.last_updated`). Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ from pyatv.interface import (
|
||||
PushListener,
|
||||
PushUpdater,
|
||||
)
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -345,7 +346,10 @@ class AppleTvMediaPlayer(
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
if play_item.path and self._is_feature_available(FeatureName.StreamFile):
|
||||
media_id = str(play_item.path)
|
||||
else:
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
media_type = MediaType.MUSIC
|
||||
|
||||
if self._is_feature_available(FeatureName.StreamFile) and (
|
||||
@@ -353,11 +357,16 @@ class AppleTvMediaPlayer(
|
||||
):
|
||||
_LOGGER.debug("Streaming %s via RAOP", media_id)
|
||||
await self.atv.stream.stream_file(media_id)
|
||||
elif self._is_feature_available(FeatureName.PlayUrl):
|
||||
elif self._is_feature_available(FeatureName.PlayUrl) and (
|
||||
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
|
||||
):
|
||||
_LOGGER.debug("Playing %s via AirPlay", media_id)
|
||||
await self.atv.stream.play_url(media_id)
|
||||
else:
|
||||
_LOGGER.error("Media streaming is not possible with current configuration")
|
||||
_LOGGER.error(
|
||||
"Media streaming is not possible with current configuration for %s",
|
||||
media_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def media_image_hash(self) -> str | None:
|
||||
|
||||
@@ -193,7 +193,11 @@ async def async_setup_entry(
|
||||
Aranet4BluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_register_processor(
|
||||
processor, AranetSensorEntityDescription
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Aranet4BluetoothSensorEntity(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -49,6 +49,20 @@ SENSORS_TYPE_COUNT = "sensors_count"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_ENTITY_MIGRATION_ID = {
|
||||
"sensor_connected_device": "Devices Connected",
|
||||
"sensor_rx_bytes": "Download",
|
||||
"sensor_tx_bytes": "Upload",
|
||||
"sensor_rx_rates": "Download Speed",
|
||||
"sensor_tx_rates": "Upload Speed",
|
||||
"sensor_load_avg1": "Load Avg (1m)",
|
||||
"sensor_load_avg5": "Load Avg (5m)",
|
||||
"sensor_load_avg15": "Load Avg (15m)",
|
||||
"2.4GHz": "2.4GHz Temperature",
|
||||
"5.0GHz": "5GHz Temperature",
|
||||
"CPU": "CPU Temperature",
|
||||
}
|
||||
|
||||
|
||||
class AsusWrtSensorDataHandler:
|
||||
"""Data handler for AsusWrt sensor."""
|
||||
@@ -187,20 +201,6 @@ class AsusWrtRouter:
|
||||
|
||||
def _migrate_entities_unique_id(self) -> None:
|
||||
"""Migrate router entities to new unique id format."""
|
||||
_ENTITY_MIGRATION_ID = {
|
||||
"sensor_connected_device": "Devices Connected",
|
||||
"sensor_rx_bytes": "Download",
|
||||
"sensor_tx_bytes": "Upload",
|
||||
"sensor_rx_rates": "Download Speed",
|
||||
"sensor_tx_rates": "Upload Speed",
|
||||
"sensor_load_avg1": "Load Avg (1m)",
|
||||
"sensor_load_avg5": "Load Avg (5m)",
|
||||
"sensor_load_avg15": "Load Avg (15m)",
|
||||
"2.4GHz": "2.4GHz Temperature",
|
||||
"5.0GHz": "5GHz Temperature",
|
||||
"CPU": "CPU Temperature",
|
||||
}
|
||||
|
||||
entity_reg = er.async_get(self.hass)
|
||||
router_entries = er.async_entries_for_config_entry(
|
||||
entity_reg, self._entry.entry_id
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
|
||||
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Light platform for Avea."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -19,6 +20,7 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
@@ -27,7 +29,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from . import AveaConfigEntry
|
||||
from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
|
||||
from .const import DOMAIN, INTEGRATION_TITLE, MODEL, UNKNOWN_NAME
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
|
||||
@@ -42,6 +44,13 @@ def _normalize_name(name: str | None) -> str | None:
|
||||
return name
|
||||
|
||||
|
||||
def _read_device_info_value(read: Callable[[], str | None]) -> str | None:
|
||||
"""Read a device information value from an Avea bulb."""
|
||||
with suppress(*UPDATE_EXCEPTIONS):
|
||||
return _normalize_name(read())
|
||||
return None
|
||||
|
||||
|
||||
def _ha_brightness_to_avea(brightness: int) -> int:
|
||||
"""Convert Home Assistant brightness to Avea brightness."""
|
||||
return round((brightness / 255) * AVEA_MAX_BRIGHTNESS)
|
||||
@@ -96,7 +105,8 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Avea light platform."""
|
||||
async_add_entities(
|
||||
[AveaLight(entry.runtime_data, entry.title)], update_before_add=True
|
||||
[AveaLight(entry.runtime_data, entry.data[CONF_ADDRESS])],
|
||||
update_before_add=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -180,14 +190,42 @@ class AveaLight(LightEntity):
|
||||
"""Representation of an Avea."""
|
||||
|
||||
_attr_color_mode = ColorMode.HS
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supported_color_modes = {ColorMode.HS}
|
||||
|
||||
def __init__(self, light: avea.Bulb, entry_title: str) -> None:
|
||||
def __init__(self, light: avea.Bulb, address: str) -> None:
|
||||
"""Initialize an AveaLight."""
|
||||
self._light = light
|
||||
self._attr_name = entry_title
|
||||
self._attr_unique_id = address
|
||||
self._attr_brightness = light.brightness
|
||||
self._last_brightness = 255
|
||||
self._device_info_updated = False
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_BLUETOOTH, address)},
|
||||
model=MODEL,
|
||||
)
|
||||
|
||||
def _update_device_info(self) -> None:
|
||||
"""Fetch device information from the Avea bulb."""
|
||||
device_info = self._attr_device_info
|
||||
assert device_info is not None
|
||||
|
||||
manufacturer = _read_device_info_value(self._light.get_manufacturer_name)
|
||||
hardware_revision = _read_device_info_value(self._light.get_hardware_revision)
|
||||
firmware_version = _read_device_info_value(self._light.get_fw_version)
|
||||
serial_number = _read_device_info_value(self._light.get_serial_number)
|
||||
|
||||
if manufacturer:
|
||||
device_info["manufacturer"] = manufacturer
|
||||
if hardware_revision:
|
||||
device_info["hw_version"] = hardware_revision
|
||||
if firmware_version:
|
||||
device_info["sw_version"] = firmware_version
|
||||
if serial_number:
|
||||
device_info["serial_number"] = serial_number
|
||||
|
||||
self._device_info_updated = True
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn on."""
|
||||
@@ -214,6 +252,8 @@ class AveaLight(LightEntity):
|
||||
connected = self._light.connect()
|
||||
|
||||
try:
|
||||
if not self._device_info_updated:
|
||||
self._update_device_info()
|
||||
brightness = self._light.get_brightness()
|
||||
rgb_color = self._light.get_rgb()
|
||||
finally:
|
||||
|
||||
@@ -51,7 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
|
||||
translation_key="invalid_bucket_name",
|
||||
) from err
|
||||
except ValueError as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_endpoint_url",
|
||||
|
||||
@@ -17,10 +17,11 @@ from homeassistant.components.backup import (
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import S3ConfigEntry
|
||||
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -8,6 +8,7 @@ from botocore.exceptions import ClientError, ConnectionError, ParamValidationErr
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
@@ -20,7 +21,6 @@ from .const import (
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_ENDPOINT_URL,
|
||||
CONF_PREFIX,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DEFAULT_ENDPOINT_URL,
|
||||
DESCRIPTION_AWS_S3_DOCS_URL,
|
||||
|
||||
@@ -11,8 +11,6 @@ CONF_ACCESS_KEY_ID = "access_key_id"
|
||||
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||
CONF_ENDPOINT_URL = "endpoint_url"
|
||||
CONF_BUCKET = "bucket"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_PREFIX = "prefix"
|
||||
|
||||
AWS_DOMAIN = "amazonaws.com"
|
||||
DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/"
|
||||
|
||||
@@ -8,10 +8,11 @@ from aiobotocore.client import AioBaseClient as S3Client
|
||||
from botocore.exceptions import BotoCoreError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_BUCKET, CONF_PREFIX, DOMAIN
|
||||
from .const import CONF_BUCKET, DOMAIN
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
SCAN_INTERVAL = timedelta(hours=6)
|
||||
|
||||
@@ -5,15 +5,10 @@ from typing import Any
|
||||
|
||||
from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_PREFIX,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DOMAIN,
|
||||
)
|
||||
from .const import CONF_ACCESS_KEY_ID, CONF_BUCKET, CONF_SECRET_ACCESS_KEY, DOMAIN
|
||||
from .coordinator import S3ConfigEntry
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
|
||||
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
|
||||
"invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
|
||||
"invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
|
||||
"invalid_endpoint_url": "[%key:component::aws_s3::exceptions::invalid_endpoint_url::message%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
@@ -48,6 +48,9 @@
|
||||
},
|
||||
"invalid_credentials": {
|
||||
"message": "Bucket cannot be accessed using provided combination of access key ID and secret access key."
|
||||
},
|
||||
"invalid_endpoint_url": {
|
||||
"message": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
from collections.abc import Mapping
|
||||
from ipaddress import ip_address
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_IGNORE,
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigEntry,
|
||||
@@ -50,6 +49,9 @@ from .const import (
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
from .hub import AxisHub, get_axis_api
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import axis
|
||||
|
||||
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f", "e8:27:25"}
|
||||
DEFAULT_PORT = 443
|
||||
DEFAULT_PROTOCOL = "https"
|
||||
@@ -94,7 +96,8 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
else:
|
||||
serial = api.vapix.serial_number
|
||||
if (serial := self._get_serial_number(api)) is None:
|
||||
return self.async_abort(reason="no_serial_number")
|
||||
config = {
|
||||
CONF_PROTOCOL: user_input[CONF_PROTOCOL],
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
@@ -139,25 +142,15 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def _create_entry(self, serial: str) -> ConfigFlowResult:
|
||||
"""Create entry for device.
|
||||
|
||||
Generate a name to be used as a prefix for device entities.
|
||||
Use the discovered device name when available.
|
||||
"""
|
||||
model = self.config[CONF_MODEL]
|
||||
same_model = [
|
||||
entry.data[CONF_NAME]
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.source != SOURCE_IGNORE and entry.data[CONF_MODEL] == model
|
||||
]
|
||||
|
||||
name = model
|
||||
for idx in range(len(same_model) + 1):
|
||||
name = f"{model} {idx}"
|
||||
if name not in same_model:
|
||||
break
|
||||
|
||||
if (title_placeholders := self.context.get("title_placeholders")) is not None:
|
||||
name = title_placeholders[CONF_NAME]
|
||||
else:
|
||||
name = f"{self.config[CONF_MODEL]} - {serial}"
|
||||
self.config[CONF_NAME] = name
|
||||
|
||||
title = f"{model} - {serial}"
|
||||
return self.async_create_entry(title=title, data=self.config)
|
||||
return self.async_create_entry(title=name, data=self.config)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -269,6 +262,19 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return await self.async_step_user()
|
||||
|
||||
@staticmethod
|
||||
def _get_serial_number(api: axis.AxisDevice) -> str | None:
|
||||
"""Retrieve the device serial number from the Axis API.
|
||||
|
||||
Tries basic_device_info first, then property_handler. Returns None if not found.
|
||||
"""
|
||||
vapix = api.vapix
|
||||
if vapix.basic_device_info.initialized:
|
||||
return vapix.basic_device_info["0"].serial_number
|
||||
if vapix.params.property_handler.initialized:
|
||||
return vapix.params.property_handler["0"].system_serial_number
|
||||
return None
|
||||
|
||||
|
||||
class AxisOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle Axis device options."""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"link_local_address": "Link local addresses are not supported",
|
||||
"no_serial_number": "Could not retrieve a serial number from the device. Please check device connectivity and try again.",
|
||||
"not_axis_device": "Discovered device not an Axis device",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .agent import BackupAgent, LocalBackupAgent, OnProgressCallback
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .models import AgentBackup, BackupNotFound
|
||||
from .models import AgentBackup, BackupNotFound, InvalidBackupFilename
|
||||
from .util import read_backup, suggested_filename
|
||||
|
||||
|
||||
@@ -54,7 +54,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
try:
|
||||
backup = read_backup(backup_path)
|
||||
backups[backup.backup_id] = (backup, backup_path)
|
||||
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
|
||||
except (
|
||||
OSError,
|
||||
TarError,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
InvalidBackupFilename,
|
||||
) as err:
|
||||
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
|
||||
return backups
|
||||
|
||||
@@ -122,7 +128,14 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
|
||||
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
||||
"""Return the local path to a new backup."""
|
||||
return self._backup_dir / suggested_filename(backup)
|
||||
candidate = self._backup_dir / suggested_filename(backup)
|
||||
# suggested_filename does not strip separators; refuse paths that would
|
||||
# land outside the backup directory.
|
||||
if candidate.parent != self._backup_dir:
|
||||
raise InvalidBackupFilename(
|
||||
f"Refusing to write outside {self._backup_dir}: {candidate}"
|
||||
)
|
||||
return candidate
|
||||
|
||||
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
||||
"""Delete a backup file."""
|
||||
|
||||
@@ -1978,7 +1978,13 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
|
||||
try:
|
||||
backup = await async_add_executor_job(read_backup, temp_file)
|
||||
except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError) as err:
|
||||
except (
|
||||
OSError,
|
||||
tarfile.TarError,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
InvalidBackupFilename,
|
||||
) as err:
|
||||
LOGGER.warning("Unable to parse backup %s: %s", temp_file, err)
|
||||
raise
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import copy
|
||||
from dataclasses import dataclass, replace
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path, PurePath
|
||||
from pathlib import Path, PurePath, PureWindowsPath
|
||||
from queue import SimpleQueue
|
||||
import tarfile
|
||||
import threading
|
||||
@@ -34,7 +34,7 @@ from homeassistant.util.async_iterator import (
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .const import BUF_SIZE, LOGGER, SECURETAR_CREATE_VERSION
|
||||
from .models import AddonInfo, AgentBackup, Folder
|
||||
from .models import AddonInfo, AgentBackup, Folder, InvalidBackupFilename
|
||||
|
||||
|
||||
class DecryptError(HomeAssistantError):
|
||||
@@ -109,6 +109,13 @@ def read_backup(backup_path: Path) -> AgentBackup:
|
||||
extra_metadata = cast(dict[str, bool | str], data.get("extra", {}))
|
||||
date = extra_metadata.get("supervisor.backup_request_date", data["date"])
|
||||
|
||||
name = cast(str, data["name"])
|
||||
# The name is used to derive the on-disk filename via suggested_filename;
|
||||
# reject anything that could escape the backup directory.
|
||||
safe_name = PureWindowsPath(name).name
|
||||
if safe_name != name or name in ("", ".", ".."):
|
||||
raise InvalidBackupFilename(f"Invalid backup name: {name!r}")
|
||||
|
||||
return AgentBackup(
|
||||
addons=addons,
|
||||
backup_id=cast(str, data["slug"]),
|
||||
@@ -118,7 +125,7 @@ def read_backup(backup_path: Path) -> AgentBackup:
|
||||
folders=folders,
|
||||
homeassistant_included=homeassistant_included,
|
||||
homeassistant_version=homeassistant_version,
|
||||
name=cast(str, data["name"]),
|
||||
name=name,
|
||||
protected=cast(bool, data.get("protected", False)),
|
||||
size=backup_path.stat().st_size,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -32,6 +32,7 @@ PLATFORMS = [
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.cover
|
||||
from blebox_uniapi.cover import BleboxCoverState
|
||||
from blebox_uniapi.cover import BleboxCoverState, UnifiedCoverType
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
@@ -25,6 +25,19 @@ BLEBOX_TO_COVER_DEVICE_CLASSES = {
|
||||
"shutter": CoverDeviceClass.SHUTTER,
|
||||
}
|
||||
|
||||
UNIFIED_COVER_TYPE_TO_DEVICE_CLASS = {
|
||||
UnifiedCoverType.AWNING: CoverDeviceClass.AWNING,
|
||||
UnifiedCoverType.BLIND: CoverDeviceClass.BLIND,
|
||||
UnifiedCoverType.CURTAIN: CoverDeviceClass.CURTAIN,
|
||||
UnifiedCoverType.DAMPER: CoverDeviceClass.DAMPER,
|
||||
UnifiedCoverType.DOOR: CoverDeviceClass.DOOR,
|
||||
UnifiedCoverType.GARAGE: CoverDeviceClass.GARAGE,
|
||||
UnifiedCoverType.GATE: CoverDeviceClass.GATE,
|
||||
UnifiedCoverType.SHADE: CoverDeviceClass.SHADE,
|
||||
UnifiedCoverType.SHUTTER: CoverDeviceClass.SHUTTER,
|
||||
UnifiedCoverType.WINDOW: CoverDeviceClass.WINDOW,
|
||||
}
|
||||
|
||||
BLEBOX_TO_HASS_COVER_STATES = {
|
||||
None: None,
|
||||
# all blebox covers
|
||||
@@ -59,7 +72,6 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
|
||||
"""Initialize a BleBox cover feature."""
|
||||
super().__init__(feature)
|
||||
self._attr_device_class = BLEBOX_TO_COVER_DEVICE_CLASSES[feature.device_class]
|
||||
self._attr_supported_features = (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
)
|
||||
@@ -76,6 +88,21 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
| CoverEntityFeature.CLOSE_TILT
|
||||
)
|
||||
|
||||
if feature.tilt_only:
|
||||
self._attr_supported_features &= ~(
|
||||
CoverEntityFeature.OPEN
|
||||
| CoverEntityFeature.CLOSE
|
||||
| CoverEntityFeature.SET_POSITION
|
||||
| CoverEntityFeature.STOP
|
||||
)
|
||||
|
||||
@property
|
||||
def device_class(self) -> CoverDeviceClass | None:
|
||||
"""Return the device class based on cover type when available."""
|
||||
if (cover_type := self._feature.cover_type) is not None:
|
||||
return UNIFIED_COVER_TYPE_TO_DEVICE_CLASS[cover_type]
|
||||
return BLEBOX_TO_COVER_DEVICE_CLASSES[self._feature.device_class]
|
||||
|
||||
@property
|
||||
def current_cover_position(self) -> int | None:
|
||||
"""Return the current cover position."""
|
||||
@@ -118,7 +145,8 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover tilt."""
|
||||
await self._feature.async_set_tilt_position(0)
|
||||
position = 50 if self._feature.is_tilt_180 else 0
|
||||
await self._feature.async_set_tilt_position(position)
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover tilt."""
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["blebox_uniapi"],
|
||||
"requirements": ["blebox-uniapi==2.5.3"],
|
||||
"requirements": ["blebox-uniapi==2.5.4"],
|
||||
"zeroconf": ["_bbxsrv._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""BleBox update entities implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any, Final
|
||||
|
||||
from blebox_uniapi.error import ConnectionError as BleBoxConnectionError, Error
|
||||
import blebox_uniapi.update
|
||||
|
||||
from homeassistant.components.update import (
|
||||
UpdateDeviceClass,
|
||||
UpdateEntity,
|
||||
UpdateEntityFeature,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
SCAN_INTERVAL = timedelta(hours=1)
|
||||
|
||||
|
||||
_POLL_INTERVAL_SECONDS: Final = 10
|
||||
_MAX_POLL_ATTEMPTS: Final = 30
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BleBoxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox update entry."""
|
||||
entities = [
|
||||
BleBoxUpdateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("updates", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
|
||||
"""Representation of BleBox updates."""
|
||||
|
||||
_attr_device_class = UpdateDeviceClass.FIRMWARE
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
|
||||
"""Initialize the update entity."""
|
||||
super().__init__(feature)
|
||||
self._in_progress_old_version: str | None = None
|
||||
self._poll_cancel: CALLBACK_TYPE | None = None
|
||||
self._poll_attempts: int = 0
|
||||
|
||||
@property
|
||||
def in_progress(self) -> bool:
|
||||
"""Return True while the device hasn't yet rebooted to the new firmware."""
|
||||
return (
|
||||
self._in_progress_old_version is not None
|
||||
and self._in_progress_old_version == self._feature.installed_version
|
||||
)
|
||||
|
||||
def _sync_sw_version(self) -> None:
|
||||
"""Sync installed firmware version to the device registry."""
|
||||
if self.device_entry:
|
||||
dr.async_get(self.hass).async_update_device(
|
||||
self.device_entry.id,
|
||||
sw_version=self._feature.installed_version,
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update state and refresh sw_version in device registry."""
|
||||
try:
|
||||
await self._feature.async_update()
|
||||
except Error as ex:
|
||||
raise HomeAssistantError(ex) from ex
|
||||
self._sync_sw_version()
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str | None:
|
||||
"""Version installed and in use."""
|
||||
return self._feature.installed_version
|
||||
|
||||
@property
|
||||
def latest_version(self) -> str | None:
|
||||
"""Latest version available for install."""
|
||||
return self._feature.latest_version
|
||||
|
||||
def _cancel_poll(self) -> None:
|
||||
if self._poll_cancel is not None:
|
||||
self._poll_cancel()
|
||||
self._poll_cancel = None
|
||||
|
||||
def _reset_progress(self) -> None:
|
||||
self._in_progress_old_version = None
|
||||
self._poll_attempts = 0
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
self._cancel_poll()
|
||||
self._in_progress_old_version = self._feature.installed_version
|
||||
self._poll_attempts = 0
|
||||
self.async_write_ha_state()
|
||||
try:
|
||||
await self._feature.async_install()
|
||||
except Error as ex:
|
||||
self._reset_progress()
|
||||
raise HomeAssistantError(ex) from ex
|
||||
self._poll_cancel = async_call_later(
|
||||
self.hass, _POLL_INTERVAL_SECONDS, self._poll_until_updated
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Cancel any pending poll timer when the entity is removed."""
|
||||
self._cancel_poll()
|
||||
|
||||
async def _poll_until_updated(self, _now: Any) -> None:
|
||||
"""Poll device until the installed version changes after OTA reboot."""
|
||||
self._poll_cancel = None
|
||||
self._poll_attempts += 1
|
||||
try:
|
||||
await self._feature.async_update()
|
||||
except BleBoxConnectionError:
|
||||
pass
|
||||
except Error:
|
||||
self._reset_progress()
|
||||
return
|
||||
else:
|
||||
self._sync_sw_version()
|
||||
if self.in_progress and self._poll_attempts < _MAX_POLL_ATTEMPTS:
|
||||
self._poll_cancel = async_call_later(
|
||||
self.hass, _POLL_INTERVAL_SECONDS, self._poll_until_updated
|
||||
)
|
||||
else:
|
||||
self._reset_progress()
|
||||
@@ -124,7 +124,9 @@ async def async_setup_entry(
|
||||
BlueMaestroBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class BlueMaestroBluetoothSensorEntity(
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_SOURCE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
@@ -40,7 +41,6 @@ from .const import (
|
||||
CONF_DETAILS,
|
||||
CONF_MODE,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
|
||||
@@ -22,9 +22,6 @@ CONF_PASSIVE = "passive"
|
||||
|
||||
DEFAULT_MODE = BluetoothScanningMode.AUTO.value
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_SOURCE: Final = "source"
|
||||
CONF_SOURCE_DOMAIN: Final = "source_domain"
|
||||
CONF_SOURCE_MODEL: Final = "source_model"
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
|
||||
|
||||
@@ -21,7 +21,11 @@ from habluetooth import (
|
||||
)
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED
|
||||
from homeassistant.const import (
|
||||
CONF_SOURCE,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
EVENT_LOGGING_CHANGED,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
@@ -33,7 +37,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.util.package import is_docker_env
|
||||
|
||||
from .const import (
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==2.1.1",
|
||||
"bleak-retry-connector==4.6.0",
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bleak==3.0.2",
|
||||
"bleak-retry-connector==4.6.1",
|
||||
"bluetooth-adapters==2.3.0",
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.3",
|
||||
"habluetooth==6.7.2"
|
||||
"dbus-fast==5.0.16",
|
||||
"habluetooth==6.7.9"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class BroadlinkRadioFrequency(BroadlinkEntity, RadioFrequencyTransmitterEntity):
|
||||
"""Representation of a Broadlink RF transmitter."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_translation_key = "rf_transmitter"
|
||||
|
||||
def __init__(self, device: BroadlinkDevice) -> None:
|
||||
"""Initialize the entity."""
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
"name": "IR emitter"
|
||||
}
|
||||
},
|
||||
"radio_frequency": {
|
||||
"rf_transmitter": {
|
||||
"name": "RF transmitter"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"day_of_week": {
|
||||
"name": "Day of week",
|
||||
|
||||
@@ -17,6 +17,7 @@ BTHOME_BLE_EVENT: Final = "bthome_ble_event"
|
||||
|
||||
EVENT_CLASS_BUTTON: Final = "button"
|
||||
EVENT_CLASS_DIMMER: Final = "dimmer"
|
||||
EVENT_CLASS_COMMAND: Final = "command"
|
||||
|
||||
CONF_EVENT_CLASS: Final = "event_class"
|
||||
CONF_EVENT_PROPERTIES: Final = "event_properties"
|
||||
|
||||
@@ -28,6 +28,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
EVENT_CLASS,
|
||||
EVENT_CLASS_BUTTON,
|
||||
EVENT_CLASS_COMMAND,
|
||||
EVENT_CLASS_DIMMER,
|
||||
EVENT_TYPE,
|
||||
)
|
||||
@@ -43,6 +44,7 @@ EVENT_TYPES_BY_EVENT_CLASS = {
|
||||
"hold_press",
|
||||
},
|
||||
EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"},
|
||||
EVENT_CLASS_COMMAND: {"off", "on", "toggle", "step_up", "step_down"},
|
||||
}
|
||||
|
||||
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
|
||||
@@ -16,6 +16,7 @@ from . import format_discovered_event_class, format_event_dispatcher_name
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
EVENT_CLASS_BUTTON,
|
||||
EVENT_CLASS_COMMAND,
|
||||
EVENT_CLASS_DIMMER,
|
||||
EVENT_PROPERTIES,
|
||||
EVENT_TYPE,
|
||||
@@ -43,6 +44,11 @@ DESCRIPTIONS_BY_EVENT_CLASS = {
|
||||
translation_key="dimmer",
|
||||
event_types=["rotate_left", "rotate_right"],
|
||||
),
|
||||
EVENT_CLASS_COMMAND: EventEntityDescription(
|
||||
key=EVENT_CLASS_COMMAND,
|
||||
translation_key="command",
|
||||
event_types=["off", "on", "toggle", "step_up", "step_down"],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.17.0"]
|
||||
"requirements": ["bthome-ble==3.23.2"]
|
||||
}
|
||||
|
||||
@@ -192,6 +192,12 @@ SENSOR_DESCRIPTIONS = {
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Light level (-)
|
||||
(BTHomeExtendedSensorDeviceClass.LIGHT_LEVEL, None): SensorEntityDescription(
|
||||
key=str(BTHomeExtendedSensorDeviceClass.LIGHT_LEVEL),
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="light_level",
|
||||
),
|
||||
# Mass sensor (kg)
|
||||
(BTHomeSensorDeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription(
|
||||
key=f"{BTHomeSensorDeviceClass.MASS}_{Units.MASS_KILOGRAMS}",
|
||||
@@ -287,6 +293,12 @@ SENSOR_DESCRIPTIONS = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="rotational_speed",
|
||||
),
|
||||
# Settings revision (-)
|
||||
(BTHomeExtendedSensorDeviceClass.SETTINGS_REVISION, None): SensorEntityDescription(
|
||||
key=str(BTHomeExtendedSensorDeviceClass.SETTINGS_REVISION),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
translation_key="settings_revision",
|
||||
),
|
||||
# Signal Strength (RSSI) (dB)
|
||||
(
|
||||
BTHomeSensorDeviceClass.SIGNAL_STRENGTH,
|
||||
|
||||
@@ -36,13 +36,19 @@
|
||||
"long_double_press": "Long Double Press",
|
||||
"long_press": "Long Press",
|
||||
"long_triple_press": "Long Triple Press",
|
||||
"off": "Off",
|
||||
"on": "On",
|
||||
"press": "Press",
|
||||
"rotate_left": "Rotate Left",
|
||||
"rotate_right": "Rotate Right",
|
||||
"step_down": "Step Down",
|
||||
"step_up": "Step Up",
|
||||
"toggle": "Toggle",
|
||||
"triple_press": "Triple Press"
|
||||
},
|
||||
"trigger_type": {
|
||||
"button": "Button \"{subtype}\"",
|
||||
"command": "Command \"{subtype}\"",
|
||||
"dimmer": "Dimmer \"{subtype}\""
|
||||
}
|
||||
},
|
||||
@@ -68,6 +74,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"command": {
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"on": "On",
|
||||
"step_down": "Step down",
|
||||
"step_up": "Step up",
|
||||
"toggle": "Toggle"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dimmer": {
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
@@ -98,6 +117,9 @@
|
||||
"gyroscope": {
|
||||
"name": "Gyroscope"
|
||||
},
|
||||
"light_level": {
|
||||
"name": "Light level"
|
||||
},
|
||||
"packet_id": {
|
||||
"name": "Packet ID"
|
||||
},
|
||||
@@ -110,6 +132,9 @@
|
||||
"rotational_speed": {
|
||||
"name": "Rotational speed"
|
||||
},
|
||||
"settings_revision": {
|
||||
"name": "Settings revision"
|
||||
},
|
||||
"text": {
|
||||
"name": "Text"
|
||||
},
|
||||
|
||||
@@ -32,8 +32,16 @@ OPTIONS_SCHEMA = KNOWN_HOSTS_SCHEMA.extend(
|
||||
vol.Required(CONF_MORE_OPTIONS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_UUID): str,
|
||||
vol.Optional(CONF_IGNORE_CEC): str,
|
||||
vol.Optional(CONF_UUID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
custom_value=True, options=[], multiple=True
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_IGNORE_CEC): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
custom_value=True, options=[], multiple=True
|
||||
),
|
||||
),
|
||||
}
|
||||
),
|
||||
SectionConfig(collapsed=True),
|
||||
@@ -109,13 +117,11 @@ class CastOptionsFlowHandler(OptionsFlow):
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the Google Cast options."""
|
||||
if user_input is not None:
|
||||
ignore_cec = _string_to_list(
|
||||
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, "")
|
||||
ignore_cec = _trim_items(
|
||||
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, [])
|
||||
)
|
||||
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
|
||||
wanted_uuid = _string_to_list(
|
||||
user_input[CONF_MORE_OPTIONS].get(CONF_UUID, "")
|
||||
)
|
||||
wanted_uuid = _trim_items(user_input[CONF_MORE_OPTIONS].get(CONF_UUID, []))
|
||||
updated_config = dict(self.config_entry.data)
|
||||
updated_config[CONF_IGNORE_CEC] = ignore_cec
|
||||
updated_config[CONF_KNOWN_HOSTS] = known_hosts
|
||||
@@ -132,9 +138,7 @@ class CastOptionsFlowHandler(OptionsFlow):
|
||||
for key in (CONF_UUID, CONF_IGNORE_CEC):
|
||||
if key not in self.config_entry.data:
|
||||
continue
|
||||
suggested[CONF_MORE_OPTIONS][key] = _list_to_string(
|
||||
self.config_entry.data[key]
|
||||
)
|
||||
suggested[CONF_MORE_OPTIONS][key] = self.config_entry.data[key]
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
@@ -143,16 +147,5 @@ class CastOptionsFlowHandler(OptionsFlow):
|
||||
)
|
||||
|
||||
|
||||
def _list_to_string(items: list[str]) -> str:
|
||||
comma_separated_string = ""
|
||||
if items:
|
||||
comma_separated_string = ",".join(items)
|
||||
return comma_separated_string
|
||||
|
||||
|
||||
def _string_to_list(string: str) -> list[str]:
|
||||
return [x.strip() for x in string.split(",") if x.strip()]
|
||||
|
||||
|
||||
def _trim_items(items: list[str]) -> list[str]:
|
||||
return [x.strip() for x in items if x.strip()]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Diagnostics for the cert_expiry integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import CertExpiryConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_HOST, "name", "title", "unique_id"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
_hass: HomeAssistant,
|
||||
entry: CertExpiryConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
entry_diagnostics = entry.as_dict()
|
||||
|
||||
coordinator = getattr(entry, "runtime_data", None)
|
||||
|
||||
coordinator_diagnostics: dict[str, Any] = {
|
||||
"host": None,
|
||||
"port": None,
|
||||
"name": None,
|
||||
"expiry_datetime": None,
|
||||
"is_cert_valid": None,
|
||||
"cert_error": None,
|
||||
"last_update_success": None,
|
||||
}
|
||||
|
||||
if coordinator is not None:
|
||||
expiry = coordinator.data.isoformat() if coordinator.data else None
|
||||
cert_error = (
|
||||
(
|
||||
f"{type(coordinator.cert_error).__module__}."
|
||||
f"{type(coordinator.cert_error).__qualname__}"
|
||||
)
|
||||
if coordinator.cert_error
|
||||
else None
|
||||
)
|
||||
|
||||
coordinator_diagnostics = {
|
||||
"host": coordinator.host,
|
||||
"port": coordinator.port,
|
||||
"name": coordinator.name,
|
||||
"expiry_datetime": expiry,
|
||||
"is_cert_valid": coordinator.is_cert_valid,
|
||||
"cert_error": cert_error,
|
||||
"last_update_success": coordinator.last_update_success,
|
||||
}
|
||||
|
||||
return {
|
||||
"entry": async_redact_data(entry_diagnostics, TO_REDACT),
|
||||
"coordinator": async_redact_data(coordinator_diagnostics, TO_REDACT),
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: Certificates are checked every 12 hours via DataUpdateCoordinator.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage:
|
||||
status: done
|
||||
comment: test_abort_on_socket_failed can be parametrized and should end in CREATE_ENTRY to test flow recovery.
|
||||
config-flow: done
|
||||
dependency-transparency:
|
||||
status: exempt
|
||||
comment: Integration has no external library dependencies.
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: todo
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Config flow only collects host/port; the integration does not authenticate.
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: Consider creating a mock_config_entry fixture and use that throughout tests.
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery: todo
|
||||
discovery-update-info: 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:
|
||||
status: exempt
|
||||
comment: Integration supports a single device per config entry.
|
||||
entity-category:
|
||||
status: todo
|
||||
comment: Extra state attributes (is_valid, error) should be moved to separate entities in the future.
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Integration supports a single device per config entry.
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -16,6 +16,10 @@
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the server to monitor.",
|
||||
"port": "The port to connect to on the server."
|
||||
},
|
||||
"title": "Reconfigure the certificate to test"
|
||||
},
|
||||
"user": {
|
||||
@@ -24,6 +28,10 @@
|
||||
"name": "The name of the certificate",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the server to monitor.",
|
||||
"port": "The port to connect to on the server."
|
||||
},
|
||||
"title": "Define the certificate to test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.notify import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LANGUAGE,
|
||||
CONF_NAME,
|
||||
CONF_RECIPIENT,
|
||||
CONF_USERNAME,
|
||||
@@ -29,8 +30,6 @@ BASE_API_URL = "https://rest.clicksend.com/v3"
|
||||
|
||||
HEADERS = {"Content-Type": CONTENT_TYPE_JSON}
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_LANGUAGE = "language"
|
||||
CONF_VOICE = "voice"
|
||||
|
||||
MALE_VOICE = "male"
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
|
||||
EntityNumericalStateChangedTriggerBase,
|
||||
EntityNumericalStateChangedTriggerWithUnitBase,
|
||||
EntityNumericalStateCrossedThresholdTriggerBase,
|
||||
@@ -26,7 +26,7 @@ from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
|
||||
CONF_HVAC_MODE = "hvac_mode"
|
||||
|
||||
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
|
||||
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_HVAC_MODE): vol.All(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -275,9 +275,13 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
)
|
||||
)
|
||||
|
||||
def should_expose(self, state: State) -> bool:
|
||||
"""If a state object should be exposed."""
|
||||
return self._should_expose_entity_id(state.entity_id)
|
||||
def should_expose(self, entity_id: str) -> bool:
|
||||
"""If an entity should be exposed."""
|
||||
entity_filter: EntityFilter = self._config[CONF_FILTER]
|
||||
if not entity_filter.empty_filter:
|
||||
return entity_filter(entity_id)
|
||||
|
||||
return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)
|
||||
|
||||
def _should_expose_legacy(self, entity_id: str) -> bool:
|
||||
"""If an entity ID should be exposed."""
|
||||
@@ -308,14 +312,6 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
and _supported_legacy(self.hass, entity_id)
|
||||
)
|
||||
|
||||
def _should_expose_entity_id(self, entity_id: str) -> bool:
|
||||
"""If an entity should be exposed."""
|
||||
entity_filter: EntityFilter = self._config[CONF_FILTER]
|
||||
if not entity_filter.empty_filter:
|
||||
return entity_filter(entity_id)
|
||||
|
||||
return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)
|
||||
|
||||
@property
|
||||
def agent_user_id(self) -> str:
|
||||
"""Return Agent User Id to use for query responses."""
|
||||
@@ -467,7 +463,7 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
|
||||
entity_id = event.data["entity_id"]
|
||||
|
||||
if not self._should_expose_entity_id(entity_id):
|
||||
if not self.should_expose(entity_id):
|
||||
return
|
||||
|
||||
self.async_schedule_google_sync_all()
|
||||
@@ -490,8 +486,7 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
|
||||
# Check if any exposed entity uses the device area
|
||||
if not any(
|
||||
entity_entry.area_id is None
|
||||
and self._should_expose_entity_id(entity_entry.entity_id)
|
||||
entity_entry.area_id is None and self.should_expose(entity_entry.entity_id)
|
||||
for entity_entry in er.async_entries_for_device(
|
||||
er.async_get(self.hass), event.data["device_id"]
|
||||
)
|
||||
|
||||
@@ -17,10 +17,11 @@ from homeassistant.components.backup import (
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import R2ConfigEntry
|
||||
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
CACHE_TTL = 300
|
||||
|
||||
@@ -13,6 +13,7 @@ from botocore.exceptions import (
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
@@ -25,7 +26,6 @@ from .const import (
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_ENDPOINT_URL,
|
||||
CONF_PREFIX,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DEFAULT_ENDPOINT_URL,
|
||||
DESCRIPTION_R2_AUTH_DOCS_URL,
|
||||
|
||||
@@ -11,8 +11,6 @@ CONF_ACCESS_KEY_ID = "access_key_id"
|
||||
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||
CONF_ENDPOINT_URL = "endpoint_url"
|
||||
CONF_BUCKET = "bucket"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_PREFIX = "prefix"
|
||||
|
||||
# R2 is S3-compatible. Endpoint should be like:
|
||||
# https://<accountid>.r2.cloudflarestorage.com
|
||||
|
||||
@@ -5,3 +5,5 @@ ATTR_URL = "color_extract_url"
|
||||
|
||||
DOMAIN = "color_extractor"
|
||||
DEFAULT_NAME = "Color extractor"
|
||||
|
||||
SERVICE_GET_COLOR = "get_color"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"services": {
|
||||
"get_color": {
|
||||
"service": "mdi:select-color"
|
||||
},
|
||||
"turn_on": {
|
||||
"service": "mdi:lightbulb-on"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from colorthief import ColorThief
|
||||
@@ -15,15 +16,16 @@ from homeassistant.components.light import (
|
||||
LIGHT_TURN_ON_SCHEMA,
|
||||
)
|
||||
from homeassistant.const import SERVICE_TURN_ON
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
|
||||
from .const import ATTR_PATH, ATTR_URL, DOMAIN
|
||||
from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_GET_COLOR
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Extend the existing light.turn_on service schema
|
||||
SERVICE_SCHEMA = vol.All(
|
||||
TURN_ON_SERVICE_SCHEMA = vol.All(
|
||||
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
@@ -34,6 +36,14 @@ SERVICE_SCHEMA = vol.All(
|
||||
),
|
||||
)
|
||||
|
||||
GET_COLOR_SERVICE_SCHEMA = vol.All(
|
||||
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
|
||||
{
|
||||
vol.Exclusive(ATTR_PATH, "color_extractor"): cv.isfile,
|
||||
vol.Exclusive(ATTR_URL, "color_extractor"): cv.url,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _get_file(file_path: str) -> str:
|
||||
"""Get a PIL acceptable input file reference.
|
||||
@@ -145,6 +155,50 @@ async def async_handle_service(service_call: ServiceCall) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def async_handle_get_color(
|
||||
service_call: ServiceCall,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle get_color service call."""
|
||||
service_data = dict(service_call.data)
|
||||
|
||||
try:
|
||||
if ATTR_URL in service_data:
|
||||
image_type = "URL"
|
||||
image_reference = service_data.pop(ATTR_URL)
|
||||
color = await _async_extract_color_from_url(
|
||||
service_call.hass, image_reference
|
||||
)
|
||||
|
||||
elif ATTR_PATH in service_data:
|
||||
image_type = "file path"
|
||||
image_reference = service_data.pop(ATTR_PATH)
|
||||
color = await service_call.hass.async_add_executor_job(
|
||||
_extract_color_from_path, service_call.hass, image_reference
|
||||
)
|
||||
|
||||
except UnidentifiedImageError as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_image",
|
||||
translation_placeholders={
|
||||
"image_type": image_type,
|
||||
"image_reference": image_reference,
|
||||
},
|
||||
) from ex
|
||||
|
||||
if color is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_image",
|
||||
translation_placeholders={
|
||||
"image_type": image_type,
|
||||
"image_reference": image_reference,
|
||||
},
|
||||
)
|
||||
|
||||
return {"color": color}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the services."""
|
||||
@@ -153,5 +207,13 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
async_handle_service,
|
||||
schema=SERVICE_SCHEMA,
|
||||
schema=TURN_ON_SERVICE_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_COLOR,
|
||||
async_handle_get_color,
|
||||
schema=GET_COLOR_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
@@ -11,3 +11,13 @@ turn_on:
|
||||
example: /opt/images/logo.png
|
||||
selector:
|
||||
text:
|
||||
get_color:
|
||||
fields:
|
||||
color_extract_url:
|
||||
example: https://www.example.com/images/logo.png
|
||||
selector:
|
||||
text:
|
||||
color_extract_path:
|
||||
example: /opt/images/logo.png
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -6,7 +6,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_image": {
|
||||
"message": "Bad image {image_reference} from {image_type} provided, are you sure it's an image?"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_color": {
|
||||
"description": "Gets the predominant RGB color found in the image provided by URL or file path.",
|
||||
"fields": {
|
||||
"color_extract_path": {
|
||||
"description": "The full system path to the image we want to extract RGB values from. Must be allowed in allowlist_external_dirs.",
|
||||
"name": "[%key:common::config_flow::data::path%]"
|
||||
},
|
||||
"color_extract_url": {
|
||||
"description": "The URL of the image we want to extract RGB values from. Must be allowed in allowlist_external_urls.",
|
||||
"name": "[%key:common::config_flow::data::url%]"
|
||||
}
|
||||
},
|
||||
"name": "Get predominant color"
|
||||
},
|
||||
"turn_on": {
|
||||
"description": "Sets the light RGB to the predominant color found in the image provided by URL or file path.",
|
||||
"fields": {
|
||||
|
||||
@@ -175,7 +175,6 @@ class ConfigManagerFlowIndexView(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("handler"): vol.Any(str, list),
|
||||
vol.Optional("show_advanced_options", default=False): cv.boolean,
|
||||
vol.Optional("entry_id"): cv.string,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -302,7 +301,6 @@ class SubentryManagerFlowIndexView(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
|
||||
vol.Optional("show_advanced_options", default=False): cv.boolean,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
@@ -60,7 +60,9 @@ class CheckConfigView(HomeAssistantView):
|
||||
vol.Optional("location_name"): str,
|
||||
vol.Optional("longitude"): cv.longitude,
|
||||
vol.Optional("radius"): cv.positive_int,
|
||||
vol.Optional("time_zone"): cv.time_zone,
|
||||
# Validated by async_set_time_zone in the executor to avoid
|
||||
# blocking I/O loading zoneinfo data on the event loop.
|
||||
vol.Optional("time_zone"): str,
|
||||
vol.Optional("update_units"): bool,
|
||||
vol.Optional("unit_system"): unit_system.validate_unit_system,
|
||||
}
|
||||
|
||||
@@ -42,6 +42,35 @@ async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) ->
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
def _migrate_identifiers(
|
||||
hass: HomeAssistant,
|
||||
config_entry: CookidooConfigEntry,
|
||||
old_prefix: str,
|
||||
new_unique_id: str,
|
||||
) -> None:
|
||||
"""Migrate device identifiers and entity unique_ids from old to new prefix."""
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry_id=config_entry.entry_id
|
||||
)
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, config_entry_id=config_entry.entry_id
|
||||
)
|
||||
for dev in device_entries:
|
||||
new_identifiers = {
|
||||
(DOMAIN, new_unique_id) if domain == DOMAIN else (domain, identifier)
|
||||
for domain, identifier in dev.identifiers
|
||||
}
|
||||
device_registry.async_update_device(dev.id, new_identifiers=new_identifiers)
|
||||
for ent in entity_entries:
|
||||
if ent.unique_id and ent.unique_id.startswith(f"{old_prefix}_"):
|
||||
entity_registry.async_update_entity(
|
||||
ent.entity_id,
|
||||
new_unique_id=f"{new_unique_id}{ent.unique_id[len(old_prefix) :]}",
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, config_entry: CookidooConfigEntry
|
||||
) -> bool:
|
||||
@@ -49,41 +78,37 @@ async def async_migrate_entry(
|
||||
_LOGGER.debug("Migrating from version %s", config_entry.version)
|
||||
|
||||
if config_entry.version == 1 and config_entry.minor_version == 1:
|
||||
# Add the unique uuid
|
||||
# Add the unique uuid (first migration, entities used config_entry_id as prefix)
|
||||
cookidoo = await cookidoo_from_config_entry(hass, config_entry)
|
||||
|
||||
try:
|
||||
auth_data = await cookidoo.login()
|
||||
await cookidoo.login()
|
||||
user_info = await cookidoo.get_user_info()
|
||||
except (CookidooRequestException, CookidooAuthException) as e:
|
||||
_LOGGER.error(
|
||||
"Could not migrate config config_entry: %s",
|
||||
str(e),
|
||||
)
|
||||
_LOGGER.error("Could not migrate config entry: %s", e)
|
||||
return False
|
||||
|
||||
unique_id = auth_data.sub
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry_id=config_entry.entry_id
|
||||
)
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, config_entry_id=config_entry.entry_id
|
||||
)
|
||||
for dev in device_entries:
|
||||
device_registry.async_update_device(
|
||||
dev.id, new_identifiers={(DOMAIN, unique_id)}
|
||||
)
|
||||
for ent in entity_entries:
|
||||
assert ent.config_entry_id
|
||||
entity_registry.async_update_entity(
|
||||
ent.entity_id,
|
||||
new_unique_id=ent.unique_id.replace(ent.config_entry_id, unique_id),
|
||||
)
|
||||
|
||||
_migrate_identifiers(hass, config_entry, config_entry.entry_id, user_info.id)
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, unique_id=auth_data.sub, minor_version=2
|
||||
config_entry, unique_id=user_info.id, minor_version=3
|
||||
)
|
||||
|
||||
if config_entry.version == 1 and config_entry.minor_version == 2:
|
||||
# Migrate unique_id from old CIAM sub to community profile id
|
||||
cookidoo = await cookidoo_from_config_entry(hass, config_entry)
|
||||
|
||||
try:
|
||||
await cookidoo.login()
|
||||
user_info = await cookidoo.get_user_info()
|
||||
except (CookidooRequestException, CookidooAuthException) as e:
|
||||
_LOGGER.error("Could not migrate config entry: %s", e)
|
||||
return False
|
||||
|
||||
old_unique_id = config_entry.unique_id
|
||||
if old_unique_id:
|
||||
_migrate_identifiers(hass, config_entry, old_unique_id, user_info.id)
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, unique_id=user_info.id, minor_version=3
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
|
||||
from cookidoo_api import CookidooAuthException, CookidooException
|
||||
from cookidoo_api import (
|
||||
CookidooAuthException,
|
||||
CookidooException,
|
||||
CookidooRequestException,
|
||||
)
|
||||
from cookidoo_api.types import CookidooCalendarDayRecipe
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
@@ -74,7 +78,13 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
|
||||
week_day
|
||||
)
|
||||
except CookidooAuthException:
|
||||
await self.coordinator.cookidoo.refresh_token()
|
||||
try:
|
||||
await self.coordinator.cookidoo.login()
|
||||
except (CookidooAuthException, CookidooRequestException) as exc:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="calendar_fetch_failed",
|
||||
) from exc
|
||||
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
|
||||
week_day
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Cookidoo."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
|
||||
COUNTRY_DATA_SCHEMA: dict
|
||||
LANGUAGE_DATA_SCHEMA: dict
|
||||
@@ -223,8 +223,9 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
cookidoo = await cookidoo_from_config_data(self.hass, data_input)
|
||||
try:
|
||||
auth_data = await cookidoo.login()
|
||||
self.user_uuid = auth_data.sub
|
||||
await cookidoo.login()
|
||||
user_info = await cookidoo.get_user_info()
|
||||
self.user_uuid = user_info.id
|
||||
if language_input:
|
||||
await cookidoo.get_additional_items()
|
||||
except CookidooRequestException:
|
||||
|
||||
@@ -87,7 +87,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
)
|
||||
except CookidooAuthException:
|
||||
try:
|
||||
await self.cookidoo.refresh_token()
|
||||
await self.cookidoo.login()
|
||||
except CookidooAuthException as exc:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -96,6 +96,11 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
CONF_EMAIL: self.config_entry.data[CONF_EMAIL]
|
||||
},
|
||||
) from exc
|
||||
except CookidooRequestException as exc:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_request_exception",
|
||||
) from exc
|
||||
_LOGGER.debug(
|
||||
"Authentication failed but re-authentication"
|
||||
" was successful, trying again later"
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
|
||||
|
||||
from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .coordinator import CookidooConfigEntry
|
||||
|
||||
@@ -21,7 +22,7 @@ async def cookidoo_from_config_data(
|
||||
)
|
||||
|
||||
return Cookidoo(
|
||||
async_get_clientsession(hass),
|
||||
async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)),
|
||||
CookidooConfig(
|
||||
email=data[CONF_EMAIL],
|
||||
password=data[CONF_PASSWORD],
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["cookidoo_api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["cookidoo-api==0.14.0"]
|
||||
"requirements": ["cookidoo-api==0.17.2"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["data-grand-lyon-ha==0.7.0"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user