forked from home-assistant/core
Compare commits
652 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb5f30e9cf | |||
| 4de124cdd5 | |||
| 5e243da470 | |||
| 33a8eb1716 | |||
| 6137aeb30a | |||
| 24428d98a1 | |||
| 3d39854ffc | |||
| 4663ad75a0 | |||
| 07aef27ea8 | |||
| 93eac97983 | |||
| 78e29d526c | |||
| 9665bc61f2 | |||
| 609a7ccda8 | |||
| 3b0068bc85 | |||
| 5b9ad6a6d3 | |||
| 459af7f834 | |||
| 2198492f1b | |||
| 09517668fe | |||
| faf78fc6b1 | |||
| 0e2541c843 | |||
| 7af63c2cdf | |||
| 79ad9a3646 | |||
| a37a87e972 | |||
| 92bb61d25b | |||
| 0552ec834d | |||
| da26b0a930 | |||
| 2e18b37291 | |||
| 72414a5864 | |||
| f9416e1c34 | |||
| beb0085b53 | |||
| 65d7e48815 | |||
| 7a20335943 | |||
| 1a18dc7425 | |||
| 458276a6a6 | |||
| c407fb0861 | |||
| 5080654776 | |||
| f4f3962ee9 | |||
| 2aa90b1d12 | |||
| dcae9a0d02 | |||
| 821b9bdb5f | |||
| cf4c491e79 | |||
| 62f76a81bb | |||
| fd3aa5338c | |||
| 8ac74c5979 | |||
| 9fdc794b36 | |||
| 672fb44041 | |||
| b3d50e67cd | |||
| 62d38b49bc | |||
| 0525ce59d7 | |||
| b4e0a1f1fc | |||
| af193094b5 | |||
| a419c78524 | |||
| a98be9dc84 | |||
| 0429b321b8 | |||
| 768c499b6f | |||
| 79de27a4a9 | |||
| 6d619579b4 | |||
| 03dcb915e3 | |||
| bb2461ea93 | |||
| d8c9ed3a64 | |||
| 0bcda9fe9c | |||
| adc472862b | |||
| 56a6244d90 | |||
| 24fe6dfc63 | |||
| 6342992791 | |||
| 8d35426c69 | |||
| 2df5d34374 | |||
| 54a659c51b | |||
| 1797dca0b8 | |||
| d70ae8afc5 | |||
| ec914815bd | |||
| dace1add1f | |||
| 4e0b8a7363 | |||
| 90e92aa9d8 | |||
| 573c15d67a | |||
| f8fa382ebc | |||
| b5ab83def4 | |||
| 9092f6a60f | |||
| 55c723753e | |||
| 9bd739df82 | |||
| eabbe8969d | |||
| 5e9bbeb4ad | |||
| 9c784ac622 | |||
| 090f59aaa2 | |||
| ebd20c8a7b | |||
| 408b2171ae | |||
| 162c36f108 | |||
| 88f5f04be8 | |||
| f3e6d6dfc0 | |||
| 85d57a046c | |||
| f74103c57e | |||
| a511e7d6bc | |||
| 6b5e82ed40 | |||
| dc3c47986b | |||
| d7eb4c4740 | |||
| 1a787bba3f | |||
| ae0cbffdd8 | |||
| bdffb1f298 | |||
| 10606c4d1e | |||
| 016e051db6 | |||
| 4132f08146 | |||
| 5f7d98f15b | |||
| f3897d8dae | |||
| 599cc4a5c6 | |||
| 2ec1359063 | |||
| f49dc65ff2 | |||
| 2530031454 | |||
| f96515b90a | |||
| 95d16c9829 | |||
| aeb19831d2 | |||
| ef7e3e27ba | |||
| 8d201b205f | |||
| 28652345bd | |||
| c6b4c88355 | |||
| c663f7677c | |||
| da4c144a5e | |||
| 81f018b7e5 | |||
| e32dacc62d | |||
| 2819ad9a16 | |||
| b5817e40f7 | |||
| e3ff7d048a | |||
| afc9e4303a | |||
| bd22e0bd43 | |||
| 9bd12f6503 | |||
| 0ecd23baee | |||
| b597415b01 | |||
| 8c1c7e1e4c | |||
| 799080eb00 | |||
| 3364f0fce2 | |||
| 6a80f5fc60 | |||
| 4c5746d6ed | |||
| 9be9defbb8 | |||
| c3e22cfa63 | |||
| f1bb4ed0ed | |||
| d1b8f2987c | |||
| 5c7d124f02 | |||
| 717898fc92 | |||
| a5b863cd75 | |||
| 88bde2a914 | |||
| dd7de48efc | |||
| 7c9242b4a7 | |||
| 3367e86686 | |||
| 9985516f80 | |||
| 752d5958dc | |||
| b875706bdd | |||
| 01046b88e5 | |||
| c88d4b09c9 | |||
| d26160a509 | |||
| 42b0602190 | |||
| 5dcc4d49c8 | |||
| fdc80e14e6 | |||
| 9680161701 | |||
| 2b6fd0df6a | |||
| 58ea657fbc | |||
| 8fe900885a | |||
| 263901841f | |||
| 5001a50876 | |||
| 4420201fe6 | |||
| ac76a2b1df | |||
| 691cd4f2ed | |||
| 4d266e99dc | |||
| 7f7909e0d1 | |||
| d16e1b4ed0 | |||
| fdc6cf3472 | |||
| 00191ace6c | |||
| 1dc0870163 | |||
| 9625444989 | |||
| 3ff03eef46 | |||
| b0e0ada512 | |||
| cb6ffa5b03 | |||
| e7373d979b | |||
| 0cf29f0f84 | |||
| 9d68cdca18 | |||
| 24538a44fc | |||
| 09c41ca2d8 | |||
| bdb55a4262 | |||
| dc03a5c81e | |||
| 323d16cc21 | |||
| 59dc0ea2e0 | |||
| 963648a333 | |||
| 72dfd95831 | |||
| 67c4de90f3 | |||
| f37b1fc9f8 | |||
| a018ba0696 | |||
| 8f8a398631 | |||
| 714ec3f023 | |||
| d173590477 | |||
| 3bc023986c | |||
| a9db39a833 | |||
| adc8a13f93 | |||
| 1379ad60c8 | |||
| 56cc6633f5 | |||
| 5ffd833fdf | |||
| 19a6530c3c | |||
| 698345e88b | |||
| bf4559719a | |||
| f5911bcad6 | |||
| f65e06dc26 | |||
| f52fd13d6d | |||
| 808830b90e | |||
| 94f35ea968 | |||
| b23cedeae9 | |||
| 47f5160154 | |||
| ce16d8eeac | |||
| fc8c5f1bbd | |||
| 025e1792db | |||
| 27f3b53872 | |||
| a061f56833 | |||
| e39f0320df | |||
| 7061b104a9 | |||
| 0ddccb26fa | |||
| 1c0b2630da | |||
| e1a5ad069c | |||
| 4e80154ebe | |||
| 32344a8488 | |||
| b7b22b79d1 | |||
| 9744e72d5a | |||
| 77a445ee16 | |||
| 473cbf7f9b | |||
| 22a1a6846d | |||
| f2997ce4cc | |||
| 592ac37436 | |||
| 445b823232 | |||
| 9e1a670e6e | |||
| a272f8dfb2 | |||
| c40836b49f | |||
| c1b7aa084c | |||
| 8ca3440f33 | |||
| 274a6fd3d7 | |||
| 8527048f07 | |||
| d320c73fb7 | |||
| 208a44e437 | |||
| 4a0988eb5d | |||
| 6dbe67e909 | |||
| c9d81bd217 | |||
| 0678ab4e45 | |||
| f0c625b2ad | |||
| 687c035bb2 | |||
| e4d2409ca4 | |||
| 27dd4cd261 | |||
| 355404a959 | |||
| 99afab723b | |||
| 02e79cf7e6 | |||
| b2416a4020 | |||
| 4366f83ac8 | |||
| 2d1ae6660b | |||
| 2c8b704a6d | |||
| d6c954a909 | |||
| ea12d7a86f | |||
| 9b2e9b8746 | |||
| d483ad820c | |||
| ff1fd86b91 | |||
| 7446ff478f | |||
| 4f507e7f57 | |||
| fd7d0fff1f | |||
| 5b389a4dbc | |||
| 722b991234 | |||
| 1ef6391e9c | |||
| 6aa1460143 | |||
| e36fd5f222 | |||
| 325733158d | |||
| a4a8f6ebc8 | |||
| e8fb7b8cf1 | |||
| 8ce17458a9 | |||
| 5b89996476 | |||
| 00847ee4bc | |||
| a409da947f | |||
| 0ba339e56c | |||
| bb15923968 | |||
| b7cc42d135 | |||
| e277bbb513 | |||
| 4e78bcb236 | |||
| 237faf62ac | |||
| 0916701a0b | |||
| 2c9e9d0fde | |||
| e40a373c4b | |||
| 4e6937d20f | |||
| 7f62ed15fa | |||
| 524832ceaf | |||
| 78fec33b17 | |||
| 3a72054f93 | |||
| eb63bc7967 | |||
| aa68d1d617 | |||
| ca101cc7d1 | |||
| e418c66d69 | |||
| d14e96942d | |||
| e65da42a39 | |||
| 3c8397a7b9 | |||
| 0d7711f787 | |||
| 62bc8df964 | |||
| 95109072b5 | |||
| 7b3a932cd9 | |||
| 2f7c5a56eb | |||
| a7093d3687 | |||
| 504cedaa87 | |||
| 1aa8e94224 | |||
| 0fee49a32e | |||
| e8142987a7 | |||
| 86fe0c9683 | |||
| ee1644c24e | |||
| f2d10473eb | |||
| cf9ada3b0e | |||
| 49079691d4 | |||
| 14b95ffe3a | |||
| 6b9d748529 | |||
| a62ede78ca | |||
| bd0378a961 | |||
| 6e9fcbfec1 | |||
| 82c80ec8d2 | |||
| a48ede7332 | |||
| 7e46d7e808 | |||
| e30c307f9f | |||
| 78cb0cd1e1 | |||
| 66b105fb21 | |||
| d442f2aedb | |||
| 3be3226aaa | |||
| 59872f1914 | |||
| 8fe597b7c6 | |||
| a730ee2c43 | |||
| 23af02b941 | |||
| 5f0d983df1 | |||
| 69f751703b | |||
| 3d1556a4a2 | |||
| d0d4ab6056 | |||
| 667a00e7f9 | |||
| 5e2b0b23c9 | |||
| fe393c84e2 | |||
| 6c7f2167ff | |||
| b1a23c5f73 | |||
| 96a3e10ff3 | |||
| 53d7e33607 | |||
| dbfd0d50ba | |||
| 77287eb021 | |||
| 90f857e926 | |||
| 2f4325246b | |||
| 9b95a04c29 | |||
| 62a6a4cd19 | |||
| 7eccef87c2 | |||
| b8abc1350a | |||
| 06e36bcff5 | |||
| 9705607db4 | |||
| 175f38b68a | |||
| fabfc59dfb | |||
| 0fb210b886 | |||
| 823eb23600 | |||
| 9793cae2d3 | |||
| 398762fdd5 | |||
| d4c10f0a98 | |||
| 0b1241cb8f | |||
| 6becf523ec | |||
| 9f5dfdc67c | |||
| 3595e2fd5a | |||
| 87c22c3ad5 | |||
| 20d0362914 | |||
| 2b46734bd3 | |||
| fca9052430 | |||
| e9f6a963a0 | |||
| 44c89a6b6c | |||
| 0d84106947 | |||
| b3b83b7bb2 | |||
| b2bcdf7c19 | |||
| 385630f9b4 | |||
| 86e9f6643f | |||
| 59a02cd08c | |||
| 8025fbf398 | |||
| 4667b27405 | |||
| 842d89f419 | |||
| fa308d8e10 | |||
| 3d426e1e2b | |||
| 03e9cb233f | |||
| e8cbf439e5 | |||
| 96c5e845e5 | |||
| 19f71b3bb9 | |||
| 571204fa44 | |||
| de3f25571d | |||
| df40b4bf9f | |||
| e5931dfce2 | |||
| 823c506296 | |||
| 479a35c499 | |||
| a01952981f | |||
| eb469d6a2f | |||
| 2fc34e7cce | |||
| 96ed4a1be1 | |||
| aa218e6f9c | |||
| 71697df3c2 | |||
| 49468ef5d0 | |||
| a6c5b5e238 | |||
| 4c767b2f72 | |||
| 397fbc0e41 | |||
| b4fec762bc | |||
| a8f1d033a0 | |||
| 60692bcfdb | |||
| 4276ce96ea | |||
| 4104659986 | |||
| ef5d8d83cb | |||
| cff493fb98 | |||
| 19ab76dad9 | |||
| 04cfd7b41d | |||
| 5c2af99520 | |||
| d67265bb66 | |||
| 6e51f0d6f5 | |||
| 82977f33ed | |||
| fb2d432d32 | |||
| 0d019a3c4c | |||
| 65b877bb77 | |||
| 2a23583d67 | |||
| 80fe5051b3 | |||
| 2dfe33d177 | |||
| 617037a92d | |||
| 3538844181 | |||
| c01b1eb013 | |||
| 0f8060fd00 | |||
| bff5b75377 | |||
| acec2fd7db | |||
| 84f58543ef | |||
| 35b642c6c8 | |||
| 7cf1926081 | |||
| 3ddfe027fd | |||
| 94817f61e5 | |||
| 58ac8404ef | |||
| 08dd0a5efa | |||
| 5eb0c35a97 | |||
| 21a873f0af | |||
| 83704f0334 | |||
| 02c749a111 | |||
| 8f60a2bdd4 | |||
| 9f7b2ba6c1 | |||
| af34e25c89 | |||
| c43dc37713 | |||
| 0d6177dbdb | |||
| f03b9036c5 | |||
| 1848a723cd | |||
| 8230a52e0a | |||
| d0e9470c7c | |||
| b50354f362 | |||
| e4b3a146be | |||
| 1861a621b2 | |||
| 0746e09256 | |||
| 0166cd082b | |||
| 0a74f946db | |||
| d04b45a821 | |||
| a5a6641bb4 | |||
| 1420cda837 | |||
| cba5751ca2 | |||
| 1f7ebe9249 | |||
| 8495da1af0 | |||
| 03caf63ec2 | |||
| e748f0c623 | |||
| 22a1c8f00f | |||
| 4a0d3e881a | |||
| 690a0f34e5 | |||
| 6642db917f | |||
| 28d85bc405 | |||
| e7c5325ba8 | |||
| 5e3796c333 | |||
| 535fb34207 | |||
| 584066b809 | |||
| f24634e198 | |||
| 3b2127b1dc | |||
| a9e14cd8d7 | |||
| e962dd64cf | |||
| 9b03d331ca | |||
| b4e12d34f6 | |||
| 2f22613cf9 | |||
| b66a99fe8a | |||
| 0cc9b2e803 | |||
| bfea1367a7 | |||
| a05fbdeedb | |||
| dfa0b5439b | |||
| 37661fe79f | |||
| edd93e989e | |||
| a4bf71b655 | |||
| 79101b31d5 | |||
| 6e4c78686e | |||
| 4f1574b859 | |||
| 6dc55e4a3a | |||
| a854a5620f | |||
| 449f18c9c1 | |||
| 5e7e96c5da | |||
| a58b3721ed | |||
| a3e66b5dde | |||
| 83dd52ab1f | |||
| da1e5f6a3c | |||
| 8f9868024c | |||
| c90396cd57 | |||
| 509c1ca99c | |||
| 431fbee641 | |||
| 28983bca85 | |||
| 601498617d | |||
| 6c208f655d | |||
| eaaf24d326 | |||
| 0c12d45581 | |||
| c2e46db76d | |||
| 47c8b7804d | |||
| 8d302aea9e | |||
| 3a73425888 | |||
| f9e4fe016f | |||
| 5835ae03bc | |||
| 71608d4795 | |||
| e38590e40a | |||
| 9e3b54f539 | |||
| 24ff2ddae5 | |||
| 621de8bb5f | |||
| 6cbf9288b5 | |||
| 9f95da7793 | |||
| cb5326b798 | |||
| 1aa6d3e896 | |||
| 1c8d4b8bb8 | |||
| 8669ee3685 | |||
| 59511cc3f7 | |||
| d4d77d9395 | |||
| 8c621699af | |||
| 7c6a32ebb5 | |||
| fa332668d6 | |||
| edaee89e34 | |||
| 682ebbd4d5 | |||
| 7e543882fc | |||
| 2d1bb6135c | |||
| 9d508ac7ae | |||
| d52ef83899 | |||
| c3091fad4c | |||
| 73d0124c98 | |||
| 1cce55d176 | |||
| ba58fc25bd | |||
| aeeadb570a | |||
| 4456557a02 | |||
| 186f47ba46 | |||
| e72c2029cb | |||
| bacbe4aa58 | |||
| 45038bac16 | |||
| 628142527d | |||
| 34245a6b3d | |||
| 1eadc63cd5 | |||
| 33a4c2c162 | |||
| eb748416ed | |||
| 34041c7564 | |||
| 37556a57af | |||
| cacd6708f0 | |||
| 3c536e31eb | |||
| 724eb7f2bd | |||
| d539bddabc | |||
| 580b20b0a8 | |||
| ddb1610e90 | |||
| 73714a6656 | |||
| 20d8bbbd0c | |||
| e10e3ee7cc | |||
| 83b7018be2 | |||
| 6d967ac535 | |||
| 77bc745bed | |||
| 8fe7b01baa | |||
| 5e5888b37a | |||
| 90de51fff3 | |||
| 89230b75be | |||
| cbe3cabf0a | |||
| c259c1afe3 | |||
| 1ff93518b5 | |||
| 51ff027fce | |||
| 2229a63acd | |||
| 17719663f0 | |||
| 0198c751b4 | |||
| 6a6b6cf826 | |||
| 22fd6138bd | |||
| 368d1c9b54 | |||
| c5a87addc1 | |||
| fc81b82932 | |||
| d32fb7c22f | |||
| b52fab0f6d | |||
| 17270979e6 | |||
| 4a4d3201f5 | |||
| 84292d4797 | |||
| 5fc103947f | |||
| 2852fe6786 | |||
| 9965d9d81d | |||
| 8263c3de23 | |||
| 00a4279d64 | |||
| b47ac524ea | |||
| 9cab05c4b9 | |||
| f1fa63281e | |||
| e94c11371d | |||
| 90d81e9844 | |||
| 44b35fea47 | |||
| 3e94f2a502 | |||
| 3e59687902 | |||
| f4c341253b | |||
| 6db96847d5 | |||
| 3f398818c5 | |||
| 01a05340c6 | |||
| 84eb9c5f97 | |||
| ad26317b75 | |||
| 8018be28ee | |||
| 44eaf70625 | |||
| 09d54428c9 | |||
| 8256d9b472 | |||
| 611d4135fd | |||
| 03137feba5 | |||
| c566303edb | |||
| 149e610bca | |||
| 469321157d | |||
| 8e77d215e7 | |||
| 9a17c437ad | |||
| 3467f4674e | |||
| 1ca7f0dc6a | |||
| 23372e8bc4 | |||
| 4f54e33f67 | |||
| ea32cc5d92 | |||
| 8cbe394028 | |||
| a616ac2b60 | |||
| ab699d17a5 | |||
| 2e26b6e0cc | |||
| 28736e2ce4 | |||
| 6153f17155 | |||
| c7e8fc9f9d | |||
| 6bad5f02c6 | |||
| b24a5750c3 | |||
| b9f0701336 | |||
| b3887a633d | |||
| d0c38c1e12 | |||
| 2e0ecf9bd9 | |||
| ed673a1b35 | |||
| 3a3c738945 | |||
| 47af325a88 | |||
| a2efe2445a | |||
| 6b0c98045e | |||
| 6f89390251 | |||
| 565f311f5c | |||
| fd55d0f2dd | |||
| cf628dbf23 | |||
| 87c4659520 | |||
| 0b72cc9f5e | |||
| 976efb437b | |||
| 642984a042 | |||
| 8d21e2b168 | |||
| ead88cc3f8 | |||
| b316ffff9b | |||
| 196f5702b8 | |||
| 0e7d7f32c1 | |||
| 3599515325 | |||
| a7040a0487 | |||
| ba32e28fc6 | |||
| 053ed3cfdc | |||
| 40cb0eeb68 | |||
| f0710bae06 | |||
| fc78290e2f | |||
| 93d1961aae | |||
| 4c21caa917 | |||
| 1023628821 | |||
| 706e6597d8 | |||
| 3bebd4318e | |||
| d0a492644d | |||
| 43a7247dde | |||
| 7010447b04 | |||
| 7c778847e7 |
+8
-6
@@ -226,6 +226,7 @@ omit =
|
||||
homeassistant/components/dublin_bus_transport/sensor.py
|
||||
homeassistant/components/dunehd/__init__.py
|
||||
homeassistant/components/dunehd/media_player.py
|
||||
homeassistant/components/dwd_weather_warnings/const.py
|
||||
homeassistant/components/dwd_weather_warnings/sensor.py
|
||||
homeassistant/components/dweet/*
|
||||
homeassistant/components/ebox/sensor.py
|
||||
@@ -479,8 +480,6 @@ omit =
|
||||
homeassistant/components/homematic/sensor.py
|
||||
homeassistant/components/homematic/switch.py
|
||||
homeassistant/components/homeworks/*
|
||||
homeassistant/components/honeywell/__init__.py
|
||||
homeassistant/components/honeywell/climate.py
|
||||
homeassistant/components/horizon/media_player.py
|
||||
homeassistant/components/hp_ilo/sensor.py
|
||||
homeassistant/components/huawei_lte/__init__.py
|
||||
@@ -938,6 +937,7 @@ omit =
|
||||
homeassistant/components/pushover/notify.py
|
||||
homeassistant/components/pushsafer/notify.py
|
||||
homeassistant/components/pyload/sensor.py
|
||||
homeassistant/components/qbittorrent/__init__.py
|
||||
homeassistant/components/qbittorrent/sensor.py
|
||||
homeassistant/components/qnap/sensor.py
|
||||
homeassistant/components/qrcode/image_processing.py
|
||||
@@ -995,6 +995,8 @@ omit =
|
||||
homeassistant/components/ridwell/switch.py
|
||||
homeassistant/components/ring/camera.py
|
||||
homeassistant/components/ripple/sensor.py
|
||||
homeassistant/components/roborock/coordinator.py
|
||||
homeassistant/components/roborock/vacuum.py
|
||||
homeassistant/components/rocketchat/notify.py
|
||||
homeassistant/components/roomba/__init__.py
|
||||
homeassistant/components/roomba/binary_sensor.py
|
||||
@@ -1100,7 +1102,9 @@ omit =
|
||||
homeassistant/components/sms/notify.py
|
||||
homeassistant/components/sms/sensor.py
|
||||
homeassistant/components/smtp/notify.py
|
||||
homeassistant/components/snapcast/*
|
||||
homeassistant/components/snapcast/__init__.py
|
||||
homeassistant/components/snapcast/media_player.py
|
||||
homeassistant/components/snapcast/server.py
|
||||
homeassistant/components/snmp/device_tracker.py
|
||||
homeassistant/components/snmp/sensor.py
|
||||
homeassistant/components/snmp/switch.py
|
||||
@@ -1379,7 +1383,6 @@ omit =
|
||||
homeassistant/components/verisure/sensor.py
|
||||
homeassistant/components/verisure/switch.py
|
||||
homeassistant/components/versasense/*
|
||||
homeassistant/components/vesync/common.py
|
||||
homeassistant/components/vesync/fan.py
|
||||
homeassistant/components/vesync/light.py
|
||||
homeassistant/components/vesync/sensor.py
|
||||
@@ -1435,7 +1438,6 @@ omit =
|
||||
homeassistant/components/xbox/media_player.py
|
||||
homeassistant/components/xbox/remote.py
|
||||
homeassistant/components/xbox/sensor.py
|
||||
homeassistant/components/xbox_live/sensor.py
|
||||
homeassistant/components/xeoma/camera.py
|
||||
homeassistant/components/xiaomi/camera.py
|
||||
homeassistant/components/xiaomi_aqara/__init__.py
|
||||
@@ -1509,7 +1511,7 @@ omit =
|
||||
homeassistant/components/zeversolar/entity.py
|
||||
homeassistant/components/zeversolar/sensor.py
|
||||
homeassistant/components/zha/websocket_api.py
|
||||
homeassistant/components/zha/core/channels/*
|
||||
homeassistant/components/zha/core/cluster_handlers/*
|
||||
homeassistant/components/zha/core/device.py
|
||||
homeassistant/components/zha/core/gateway.py
|
||||
homeassistant/components/zha/core/helpers.py
|
||||
|
||||
@@ -24,12 +24,12 @@ jobs:
|
||||
publish: ${{ steps.version.outputs.publish }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -67,10 +67,10 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -249,7 +249,7 @@ jobs:
|
||||
- yellow
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
|
||||
- name: Set build additional args
|
||||
run: |
|
||||
@@ -292,7 +292,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master
|
||||
@@ -331,7 +331,7 @@ jobs:
|
||||
- "homeassistant"
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'homeassistant'
|
||||
|
||||
+39
-35
@@ -31,7 +31,7 @@ env:
|
||||
CACHE_VERSION: 5
|
||||
PIP_CACHE_VERSION: 4
|
||||
MYPY_CACHE_VERSION: 4
|
||||
HA_SHORT_VERSION: 2023.4
|
||||
HA_SHORT_VERSION: 2023.5
|
||||
DEFAULT_PYTHON: "3.10"
|
||||
ALL_PYTHON_VERSIONS: "['3.10', '3.11']"
|
||||
# 10.3 is the oldest supported version
|
||||
@@ -40,7 +40,9 @@ env:
|
||||
# - 10.6.10 is the version currently shipped with the Add-on (as of 31 Jan 2023)
|
||||
# 10.10 is the latest short-term-support
|
||||
# - 10.10.3 is the latest (as of 6 Feb 2023)
|
||||
MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3']"
|
||||
# mysql 8.0.32 does not always behave the same as MariaDB
|
||||
# and some queries that work on MariaDB do not work on MySQL
|
||||
MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mysql:8.0.32']"
|
||||
# 12 is the oldest supported version
|
||||
# - 12.14 is the latest (as of 9 Feb 2023)
|
||||
# 15 is the latest version
|
||||
@@ -79,7 +81,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Generate partial Python venv restore key
|
||||
id: generate_python_cache_key
|
||||
run: >-
|
||||
@@ -203,10 +205,10 @@ jobs:
|
||||
- info
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -248,9 +250,9 @@ jobs:
|
||||
- pre-commit
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -294,9 +296,9 @@ jobs:
|
||||
- pre-commit
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -343,9 +345,9 @@ jobs:
|
||||
- pre-commit
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -381,9 +383,9 @@ jobs:
|
||||
- pre-commit
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -434,6 +436,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
shopt -s globstar
|
||||
pre-commit run --hook-stage manual prettier --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*}
|
||||
|
||||
- name: Register check executables problem matcher
|
||||
@@ -487,10 +490,10 @@ jobs:
|
||||
python-version: ${{ fromJSON(needs.info.outputs.python_versions) }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -539,7 +542,7 @@ jobs:
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<23.1" setuptools wheel
|
||||
pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<23.2" setuptools wheel
|
||||
pip install --cache-dir=$PIP_CACHE -r requirements_all.txt --use-deprecated=legacy-resolver
|
||||
pip install --cache-dir=$PIP_CACHE -r requirements_test.txt --use-deprecated=legacy-resolver
|
||||
pip install -e .
|
||||
@@ -555,10 +558,10 @@ jobs:
|
||||
- base
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -587,10 +590,10 @@ jobs:
|
||||
- base
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -620,10 +623,10 @@ jobs:
|
||||
- base
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -664,10 +667,10 @@ jobs:
|
||||
- base
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -730,10 +733,10 @@ jobs:
|
||||
name: Run pip check ${{ matrix.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -783,10 +786,10 @@ jobs:
|
||||
bluez \
|
||||
ffmpeg
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -909,10 +912,10 @@ jobs:
|
||||
ffmpeg \
|
||||
libmariadb-dev-compat
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -1017,10 +1020,10 @@ jobs:
|
||||
ffmpeg \
|
||||
postgresql-server-dev-14
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -1091,19 +1094,20 @@ jobs:
|
||||
needs:
|
||||
- info
|
||||
- pytest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
- name: Upload coverage to Codecov (full coverage)
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@v3.1.1
|
||||
uses: codecov/codecov-action@v3.1.3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
- name: Upload coverage to Codecov (partial coverage)
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@v3.1.1
|
||||
uses: codecov/codecov-action@v3.1.3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
|
||||
@@ -19,10 +19,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
architectures: ${{ steps.info.outputs.architectures }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
|
||||
- name: Get information
|
||||
id: info
|
||||
@@ -72,17 +72,18 @@ jobs:
|
||||
path: ./requirements_diff.txt
|
||||
|
||||
core:
|
||||
name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for core
|
||||
name: Build musllinux wheels with musllinux_1_2 / ${{ matrix.abi }} at ${{ matrix.arch }} for core
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
abi: ["cp310", "cp311"]
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@v3
|
||||
@@ -95,9 +96,9 @@ jobs:
|
||||
name: requirements_diff
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2022.10.1
|
||||
uses: home-assistant/wheels@2023.04.0
|
||||
with:
|
||||
abi: cp310
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
@@ -109,17 +110,18 @@ jobs:
|
||||
requirements: "requirements.txt"
|
||||
|
||||
integrations:
|
||||
name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for integrations
|
||||
name: Build musllinux wheels with musllinux_1_2 / ${{ matrix.abi }} at ${{ matrix.arch }} for integrations
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
abi: ["cp310", "cp311"]
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v3.5.2
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@v3
|
||||
@@ -171,30 +173,30 @@ jobs:
|
||||
sed -i "/numpy/d" homeassistant/package_constraints.txt
|
||||
|
||||
- name: Build wheels (part 1)
|
||||
uses: home-assistant/wheels@2022.10.1
|
||||
uses: home-assistant/wheels@2023.04.0
|
||||
with:
|
||||
abi: cp310
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
|
||||
skip-binary: aiohttp;grpcio;sqlalchemy
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
|
||||
skip-binary: aiohttp;grpcio;sqlalchemy;protobuf
|
||||
legacy: true
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtaa"
|
||||
|
||||
- name: Build wheels (part 2)
|
||||
uses: home-assistant/wheels@2022.10.1
|
||||
uses: home-assistant/wheels@2023.04.0
|
||||
with:
|
||||
abi: cp310
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
|
||||
skip-binary: aiohttp;grpcio;sqlalchemy
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
|
||||
skip-binary: aiohttp;grpcio;sqlalchemy;protobuf
|
||||
legacy: true
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
repos:
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: v0.0.256
|
||||
rev: v0.0.262
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
- --fix
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.1.0
|
||||
rev: 23.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
args:
|
||||
|
||||
@@ -61,6 +61,7 @@ homeassistant.components.anthemav.*
|
||||
homeassistant.components.apcupsd.*
|
||||
homeassistant.components.aqualogic.*
|
||||
homeassistant.components.aseko_pool_live.*
|
||||
homeassistant.components.assist_pipeline.*
|
||||
homeassistant.components.asuswrt.*
|
||||
homeassistant.components.auth.*
|
||||
homeassistant.components.automation.*
|
||||
@@ -137,6 +138,7 @@ homeassistant.components.hardkernel.*
|
||||
homeassistant.components.hardware.*
|
||||
homeassistant.components.here_travel_time.*
|
||||
homeassistant.components.history.*
|
||||
homeassistant.components.homeassistant.exposed_entities
|
||||
homeassistant.components.homeassistant.triggers.event
|
||||
homeassistant.components.homeassistant_alerts.*
|
||||
homeassistant.components.homeassistant_hardware.*
|
||||
|
||||
+27
-18
@@ -80,6 +80,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/android_ip_webcam/ @engrbm87
|
||||
/homeassistant/components/androidtv/ @JeffLIrion @ollo69
|
||||
/tests/components/androidtv/ @JeffLIrion @ollo69
|
||||
/homeassistant/components/androidtv_remote/ @tronikos
|
||||
/tests/components/androidtv_remote/ @tronikos
|
||||
/homeassistant/components/anthemav/ @hyralex
|
||||
/tests/components/anthemav/ @hyralex
|
||||
/homeassistant/components/apache_kafka/ @bachya
|
||||
@@ -103,6 +105,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/arris_tg2492lg/ @vanbalken
|
||||
/homeassistant/components/aseko_pool_live/ @milanmeu
|
||||
/tests/components/aseko_pool_live/ @milanmeu
|
||||
/homeassistant/components/assist_pipeline/ @balloob @synesthesiam
|
||||
/tests/components/assist_pipeline/ @balloob @synesthesiam
|
||||
/homeassistant/components/asuswrt/ @kennedyshead @ollo69
|
||||
/tests/components/asuswrt/ @kennedyshead @ollo69
|
||||
/homeassistant/components/atag/ @MatsNL
|
||||
@@ -168,6 +172,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am
|
||||
/homeassistant/components/brother/ @bieniu
|
||||
/tests/components/brother/ @bieniu
|
||||
/homeassistant/components/brottsplatskartan/ @gjohansson-ST
|
||||
/tests/components/brottsplatskartan/ @gjohansson-ST
|
||||
/homeassistant/components/brunt/ @eavanvalkenburg
|
||||
/tests/components/brunt/ @eavanvalkenburg
|
||||
/homeassistant/components/bsblan/ @liudger
|
||||
@@ -215,8 +221,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/conversation/ @home-assistant/core @synesthesiam
|
||||
/homeassistant/components/coolmaster/ @OnFreund
|
||||
/tests/components/coolmaster/ @OnFreund
|
||||
/homeassistant/components/coronavirus/ @home-assistant/core
|
||||
/tests/components/coronavirus/ @home-assistant/core
|
||||
/homeassistant/components/counter/ @fabaff
|
||||
/tests/components/counter/ @fabaff
|
||||
/homeassistant/components/cover/ @home-assistant/core
|
||||
@@ -228,8 +232,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/cups/ @fabaff
|
||||
/homeassistant/components/daikin/ @fredrike
|
||||
/tests/components/daikin/ @fredrike
|
||||
/homeassistant/components/darksky/ @fabaff
|
||||
/tests/components/darksky/ @fabaff
|
||||
/homeassistant/components/debugpy/ @frenck
|
||||
/tests/components/debugpy/ @frenck
|
||||
/homeassistant/components/deconz/ @Kane610
|
||||
@@ -283,7 +285,7 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dsmr_reader/ @depl0y @glodenox
|
||||
/homeassistant/components/dunehd/ @bieniu
|
||||
/tests/components/dunehd/ @bieniu
|
||||
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95
|
||||
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo
|
||||
/homeassistant/components/dynalite/ @ziv1234
|
||||
/tests/components/dynalite/ @ziv1234
|
||||
/homeassistant/components/eafm/ @Jc2k
|
||||
@@ -651,8 +653,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/lidarr/ @tkdrob
|
||||
/homeassistant/components/life360/ @pnbruckner
|
||||
/tests/components/life360/ @pnbruckner
|
||||
/homeassistant/components/lifx/ @bdraco @Djelibeybi
|
||||
/tests/components/lifx/ @bdraco @Djelibeybi
|
||||
/homeassistant/components/lifx/ @bdraco
|
||||
/tests/components/lifx/ @bdraco
|
||||
/homeassistant/components/light/ @home-assistant/core
|
||||
/tests/components/light/ @home-assistant/core
|
||||
/homeassistant/components/linux_battery/ @fabaff
|
||||
@@ -933,6 +935,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/pvpc_hourly_pricing/ @azogue
|
||||
/tests/components/pvpc_hourly_pricing/ @azogue
|
||||
/homeassistant/components/qbittorrent/ @geoffreylagaisse
|
||||
/tests/components/qbittorrent/ @geoffreylagaisse
|
||||
/homeassistant/components/qingping/ @bdraco @skgsergio
|
||||
/tests/components/qingping/ @bdraco @skgsergio
|
||||
/homeassistant/components/qld_bushfire/ @exxamalte
|
||||
@@ -960,6 +963,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/rainmachine/ @bachya
|
||||
/homeassistant/components/random/ @fabaff
|
||||
/tests/components/random/ @fabaff
|
||||
/homeassistant/components/rapt_ble/ @sairon
|
||||
/tests/components/rapt_ble/ @sairon
|
||||
/homeassistant/components/raspberry_pi/ @home-assistant/core
|
||||
/tests/components/raspberry_pi/ @home-assistant/core
|
||||
/homeassistant/components/rdw/ @frenck
|
||||
@@ -978,6 +983,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/repairs/ @home-assistant/core
|
||||
/tests/components/repairs/ @home-assistant/core
|
||||
/homeassistant/components/repetier/ @MTrab @ShadowBr0ther
|
||||
/homeassistant/components/rest/ @epenet
|
||||
/tests/components/rest/ @epenet
|
||||
/homeassistant/components/rflink/ @javicalle
|
||||
/tests/components/rflink/ @javicalle
|
||||
/homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221
|
||||
@@ -992,6 +999,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/rituals_perfume_genie/ @milanmeu
|
||||
/homeassistant/components/rmvtransport/ @cgtobi
|
||||
/tests/components/rmvtransport/ @cgtobi
|
||||
/homeassistant/components/roborock/ @humbertogontijo @Lash-L
|
||||
/tests/components/roborock/ @humbertogontijo @Lash-L
|
||||
/homeassistant/components/roku/ @ctalkington
|
||||
/tests/components/roku/ @ctalkington
|
||||
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn
|
||||
@@ -1104,6 +1113,7 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/smhi/ @gjohansson-ST
|
||||
/homeassistant/components/sms/ @ocalvo
|
||||
/homeassistant/components/snapcast/ @luar123
|
||||
/tests/components/snapcast/ @luar123
|
||||
/homeassistant/components/snooz/ @AustinBrunkhorst
|
||||
/tests/components/snooz/ @AustinBrunkhorst
|
||||
/homeassistant/components/solaredge/ @frenck
|
||||
@@ -1132,8 +1142,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/splunk/ @Bre77
|
||||
/homeassistant/components/spotify/ @frenck
|
||||
/tests/components/spotify/ @frenck
|
||||
/homeassistant/components/sql/ @dgomes @gjohansson-ST
|
||||
/tests/components/sql/ @dgomes @gjohansson-ST
|
||||
/homeassistant/components/sql/ @dgomes @gjohansson-ST @dougiteixeira
|
||||
/tests/components/sql/ @dgomes @gjohansson-ST @dougiteixeira
|
||||
/homeassistant/components/squeezebox/ @rajlaud
|
||||
/tests/components/squeezebox/ @rajlaud
|
||||
/homeassistant/components/srp_energy/ @briglx
|
||||
@@ -1155,8 +1165,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/stookwijzer/ @fwestenberg
|
||||
/homeassistant/components/stream/ @hunterjm @uvjustin @allenporter
|
||||
/tests/components/stream/ @hunterjm @uvjustin @allenporter
|
||||
/homeassistant/components/stt/ @pvizeli
|
||||
/tests/components/stt/ @pvizeli
|
||||
/homeassistant/components/stt/ @home-assistant/core @pvizeli
|
||||
/tests/components/stt/ @home-assistant/core @pvizeli
|
||||
/homeassistant/components/subaru/ @G-Two
|
||||
/tests/components/subaru/ @G-Two
|
||||
/homeassistant/components/suez_water/ @ooii
|
||||
@@ -1251,8 +1261,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/trafikverket_weatherstation/ @endor-force @gjohansson-ST
|
||||
/homeassistant/components/transmission/ @engrbm87 @JPHutchins
|
||||
/tests/components/transmission/ @engrbm87 @JPHutchins
|
||||
/homeassistant/components/tts/ @pvizeli
|
||||
/tests/components/tts/ @pvizeli
|
||||
/homeassistant/components/tts/ @home-assistant/core @pvizeli
|
||||
/tests/components/tts/ @home-assistant/core @pvizeli
|
||||
/homeassistant/components/tuya/ @Tuya @zlinoliver @frenck
|
||||
/tests/components/tuya/ @Tuya @zlinoliver @frenck
|
||||
/homeassistant/components/twentemilieu/ @frenck
|
||||
@@ -1301,8 +1311,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/version/ @ludeeus
|
||||
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey
|
||||
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey
|
||||
/homeassistant/components/vicare/ @oischinger
|
||||
/tests/components/vicare/ @oischinger
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
/tests/components/vilfo/ @ManneW
|
||||
/homeassistant/components/vivotek/ @HarlemSquirrel
|
||||
@@ -1310,8 +1318,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vizio/ @raman325
|
||||
/homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare
|
||||
/tests/components/vlc_telnet/ @rodripf @MartinHjelmare
|
||||
/homeassistant/components/voice_assistant/ @balloob @synesthesiam
|
||||
/tests/components/voice_assistant/ @balloob @synesthesiam
|
||||
/homeassistant/components/voip/ @balloob @synesthesiam
|
||||
/tests/components/voip/ @balloob @synesthesiam
|
||||
/homeassistant/components/volumio/ @OnFreund
|
||||
/tests/components/volumio/ @OnFreund
|
||||
/homeassistant/components/volvooncall/ @molobrakos
|
||||
@@ -1363,9 +1371,10 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/worldclock/ @fabaff
|
||||
/homeassistant/components/ws66i/ @ssaenger
|
||||
/tests/components/ws66i/ @ssaenger
|
||||
/homeassistant/components/wyoming/ @balloob @synesthesiam
|
||||
/tests/components/wyoming/ @balloob @synesthesiam
|
||||
/homeassistant/components/xbox/ @hunterjm
|
||||
/tests/components/xbox/ @hunterjm
|
||||
/homeassistant/components/xbox_live/ @MartinHjelmare
|
||||
/homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi
|
||||
/tests/components/xiaomi_aqara/ @danielhiversen @syssi
|
||||
/homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79
|
||||
|
||||
+2
-2
@@ -132,8 +132,8 @@ For answers to common questions about this code of conduct, see the FAQ at
|
||||
<https://www.contributor-covenant.org/faq>. Translations are available at
|
||||
<https://www.contributor-covenant.org/translations>.
|
||||
|
||||
[coc-blog]: /blog/2017/01/21/home-assistant-governance/
|
||||
[coc2-blog]: /blog/2020/05/25/code-of-conduct-updated/
|
||||
[coc-blog]: https://www.home-assistant.io/blog/2017/01/21/home-assistant-governance/
|
||||
[coc2-blog]: https://www.home-assistant.io/blog/2020/05/25/code-of-conduct-updated/
|
||||
[email]: mailto:safety@home-assistant.io
|
||||
[homepage]: http://contributor-covenant.org
|
||||
[mozilla]: https://github.com/mozilla/diversity
|
||||
|
||||
+6
-5
@@ -4,11 +4,12 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Uninstall pre-installed formatting and linting tools
|
||||
# They would conflict with our pinned versions
|
||||
RUN pipx uninstall black
|
||||
RUN pipx uninstall pydocstyle
|
||||
RUN pipx uninstall pycodestyle
|
||||
RUN pipx uninstall mypy
|
||||
RUN pipx uninstall pylint
|
||||
RUN \
|
||||
pipx uninstall black \
|
||||
&& pipx uninstall pydocstyle \
|
||||
&& pipx uninstall pycodestyle \
|
||||
&& pipx uninstall mypy \
|
||||
&& pipx uninstall pylint
|
||||
|
||||
RUN \
|
||||
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
|
||||
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
image: homeassistant/{arch}-homeassistant
|
||||
shadow_repository: ghcr.io/home-assistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.02.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.02.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.02.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.02.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.02.0
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.04.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.04.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.04.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.04.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.04.0
|
||||
codenotary:
|
||||
signer: notary@home-assistant.io
|
||||
base_image: notary@home-assistant.io
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 164 KiB |
@@ -239,6 +239,7 @@ async def load_registries(hass: core.HomeAssistant) -> None:
|
||||
|
||||
# Load the registries and cache the result of platform.uname().processor
|
||||
entity.async_setup(hass)
|
||||
template.async_setup(hass)
|
||||
await asyncio.gather(
|
||||
area_registry.async_load(hass),
|
||||
device_registry.async_load(hass),
|
||||
@@ -628,6 +629,9 @@ async def _async_set_up_integrations(
|
||||
- stage_1_domains
|
||||
)
|
||||
|
||||
# Enables after dependencies when setting up stage 1 domains
|
||||
async_set_domains_to_be_loaded(hass, stage_1_domains)
|
||||
|
||||
# Start setup
|
||||
if stage_1_domains:
|
||||
_LOGGER.info("Setting up stage 1: %s", stage_1_domains)
|
||||
@@ -639,7 +643,7 @@ async def _async_set_up_integrations(
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.warning("Setup timed out for stage 1 - moving forward")
|
||||
|
||||
# Enables after dependencies
|
||||
# Add after dependencies when setting up stage 2 domains
|
||||
async_set_domains_to_be_loaded(hass, stage_2_domains)
|
||||
|
||||
if stage_2_domains:
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"microsoft_face",
|
||||
"microsoft",
|
||||
"msteams",
|
||||
"xbox",
|
||||
"xbox_live"
|
||||
"xbox"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,14 +10,15 @@ from aiohttp import ClientSession
|
||||
from aiohttp.client_exceptions import ClientConnectorError
|
||||
from async_timeout import timeout
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER
|
||||
|
||||
@@ -49,6 +50,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Remove ozone sensors from registry if they exist
|
||||
ent_reg = er.async_get(hass)
|
||||
for day in range(0, 5):
|
||||
unique_id = f"{coordinator.location_key}-ozone-{day}"
|
||||
if entity_id := ent_reg.async_get_entity_id(SENSOR_PLATFORM, DOMAIN, unique_id):
|
||||
_LOGGER.debug("Removing ozone sensor entity %s", entity_id)
|
||||
ent_reg.async_remove(entity_id)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -116,11 +125,7 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
async with timeout(10):
|
||||
current = await self.accuweather.async_get_current_conditions()
|
||||
forecast = (
|
||||
await self.accuweather.async_get_forecast(
|
||||
metric=self.hass.config.units is METRIC_SYSTEM
|
||||
)
|
||||
if self.forecast
|
||||
else {}
|
||||
await self.accuweather.async_get_forecast() if self.forecast else {}
|
||||
)
|
||||
except (
|
||||
ApiError,
|
||||
|
||||
@@ -20,7 +20,6 @@ from homeassistant.components.weather import (
|
||||
ATTR_CONDITION_WINDY,
|
||||
)
|
||||
|
||||
API_IMPERIAL: Final = "Imperial"
|
||||
API_METRIC: Final = "Metric"
|
||||
ATTRIBUTION: Final = "Data provided by AccuWeather"
|
||||
ATTR_CATEGORY: Final = "Category"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["accuweather==0.5.0"]
|
||||
"requirements": ["accuweather==0.5.1"]
|
||||
}
|
||||
|
||||
@@ -26,11 +26,9 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from . import AccuWeatherDataUpdateCoordinator
|
||||
from .const import (
|
||||
API_IMPERIAL,
|
||||
API_METRIC,
|
||||
ATTR_CATEGORY,
|
||||
ATTR_DIRECTION,
|
||||
@@ -51,7 +49,7 @@ PARALLEL_UPDATES = 1
|
||||
class AccuWeatherSensorDescriptionMixin:
|
||||
"""Mixin for AccuWeather sensor."""
|
||||
|
||||
value_fn: Callable[[dict[str, Any], str], StateType]
|
||||
value_fn: Callable[[dict[str, Any]], StateType]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -61,8 +59,6 @@ class AccuWeatherSensorDescription(
|
||||
"""Class describing AccuWeather sensor entities."""
|
||||
|
||||
attr_fn: Callable[[dict[str, Any]], dict[str, StateType]] = lambda _: {}
|
||||
metric_unit: str | None = None
|
||||
us_customary_unit: str | None = None
|
||||
|
||||
|
||||
FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
@@ -72,7 +68,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="Cloud cover day",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda data, _: cast(int, data),
|
||||
value_fn=lambda data: cast(int, data),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="CloudCoverNight",
|
||||
@@ -80,7 +76,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="Cloud cover night",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda data, _: cast(int, data),
|
||||
value_fn=lambda data: cast(int, data),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Grass",
|
||||
@@ -88,7 +84,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="Grass pollen",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
value_fn=lambda data, _: cast(int, data[ATTR_VALUE]),
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
@@ -96,7 +92,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
icon="mdi:weather-partly-cloudy",
|
||||
name="Hours of sun",
|
||||
native_unit_of_measurement=UnitOfTime.HOURS,
|
||||
value_fn=lambda data, _: cast(float, data),
|
||||
value_fn=lambda data: cast(float, data),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Mold",
|
||||
@@ -104,15 +100,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="Mold pollen",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
value_fn=lambda data, _: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Ozone",
|
||||
icon="mdi:vector-triangle",
|
||||
name="Ozone",
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data, _: cast(int, data[ATTR_VALUE]),
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
@@ -121,56 +109,52 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="Ragweed pollen",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data, _: cast(int, data[ATTR_VALUE]),
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="RealFeelTemperatureMax",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
name="RealFeel temperature max",
|
||||
metric_unit=UnitOfTemperature.CELSIUS,
|
||||
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
|
||||
value_fn=lambda data, _: cast(float, data[ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="RealFeelTemperatureMin",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
name="RealFeel temperature min",
|
||||
metric_unit=UnitOfTemperature.CELSIUS,
|
||||
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
|
||||
value_fn=lambda data, _: cast(float, data[ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="RealFeelTemperatureShadeMax",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
name="RealFeel temperature shade max",
|
||||
entity_registry_enabled_default=False,
|
||||
metric_unit=UnitOfTemperature.CELSIUS,
|
||||
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
|
||||
value_fn=lambda data, _: cast(float, data[ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="RealFeelTemperatureShadeMin",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
name="RealFeel temperature shade min",
|
||||
entity_registry_enabled_default=False,
|
||||
metric_unit=UnitOfTemperature.CELSIUS,
|
||||
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
|
||||
value_fn=lambda data, _: cast(float, data[ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="ThunderstormProbabilityDay",
|
||||
icon="mdi:weather-lightning",
|
||||
name="Thunderstorm probability day",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda data, _: cast(int, data),
|
||||
value_fn=lambda data: cast(int, data),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="ThunderstormProbabilityNight",
|
||||
icon="mdi:weather-lightning",
|
||||
name="Thunderstorm probability night",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda data, _: cast(int, data),
|
||||
value_fn=lambda data: cast(int, data),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Tree",
|
||||
@@ -178,7 +162,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="Tree pollen",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data, _: cast(int, data[ATTR_VALUE]),
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
@@ -186,7 +170,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
icon="mdi:weather-sunny",
|
||||
name="UV index",
|
||||
native_unit_of_measurement=UV_INDEX,
|
||||
value_fn=lambda data, _: cast(int, data[ATTR_VALUE]),
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
@@ -194,9 +178,8 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
name="Wind gust day",
|
||||
entity_registry_enabled_default=False,
|
||||
metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
us_customary_unit=UnitOfSpeed.MILES_PER_HOUR,
|
||||
value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
|
||||
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
@@ -204,27 +187,24 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
name="Wind gust night",
|
||||
entity_registry_enabled_default=False,
|
||||
metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
us_customary_unit=UnitOfSpeed.MILES_PER_HOUR,
|
||||
value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
|
||||
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="WindDay",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
name="Wind day",
|
||||
metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
us_customary_unit=UnitOfSpeed.MILES_PER_HOUR,
|
||||
value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
|
||||
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="WindNight",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
name="Wind night",
|
||||
metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
us_customary_unit=UnitOfSpeed.MILES_PER_HOUR,
|
||||
value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
|
||||
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
|
||||
),
|
||||
)
|
||||
@@ -236,9 +216,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="Apparent temperature",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
metric_unit=UnitOfTemperature.CELSIUS,
|
||||
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
|
||||
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Ceiling",
|
||||
@@ -246,9 +225,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
icon="mdi:weather-fog",
|
||||
name="Cloud ceiling",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
metric_unit=UnitOfLength.METERS,
|
||||
us_customary_unit=UnitOfLength.FEET,
|
||||
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfLength.METERS,
|
||||
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
@@ -258,7 +236,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda data, _: cast(int, data),
|
||||
value_fn=lambda data: cast(int, data),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="DewPoint",
|
||||
@@ -266,18 +244,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="Dew point",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
metric_unit=UnitOfTemperature.CELSIUS,
|
||||
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
|
||||
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="RealFeelTemperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
name="RealFeel temperature",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
metric_unit=UnitOfTemperature.CELSIUS,
|
||||
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
|
||||
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="RealFeelTemperatureShade",
|
||||
@@ -285,18 +261,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="RealFeel temperature shade",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
metric_unit=UnitOfTemperature.CELSIUS,
|
||||
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
|
||||
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Precipitation",
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
name="Precipitation",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
metric_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
|
||||
us_customary_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR,
|
||||
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
|
||||
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
|
||||
attr_fn=lambda data: {"type": data["PrecipitationType"]},
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
@@ -306,7 +280,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="Pressure tendency",
|
||||
options=["falling", "rising", "steady"],
|
||||
translation_key="pressure_tendency",
|
||||
value_fn=lambda data, _: cast(str, data["LocalizedText"]).lower(),
|
||||
value_fn=lambda data: cast(str, data["LocalizedText"]).lower(),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="UVIndex",
|
||||
@@ -314,7 +288,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="UV index",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UV_INDEX,
|
||||
value_fn=lambda data, _: cast(int, data),
|
||||
value_fn=lambda data: cast(int, data),
|
||||
attr_fn=lambda data: {ATTR_LEVEL: data["UVIndexText"]},
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
@@ -323,9 +297,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="Wet bulb temperature",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
metric_unit=UnitOfTemperature.CELSIUS,
|
||||
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
|
||||
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="WindChillTemperature",
|
||||
@@ -333,18 +306,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="Wind chill temperature",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
metric_unit=UnitOfTemperature.CELSIUS,
|
||||
us_customary_unit=UnitOfTemperature.FAHRENHEIT,
|
||||
value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Wind",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
name="Wind",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
us_customary_unit=UnitOfSpeed.MILES_PER_HOUR,
|
||||
value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]),
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="WindGust",
|
||||
@@ -352,9 +323,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
name="Wind gust",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
us_customary_unit=UnitOfSpeed.MILES_PER_HOUR,
|
||||
value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]),
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -374,7 +344,7 @@ async def async_setup_entry(
|
||||
# Some air quality/allergy sensors are only available for certain
|
||||
# locations.
|
||||
sensors.extend(
|
||||
AccuWeatherForecastSensor(coordinator, description, forecast_day=day)
|
||||
AccuWeatherSensor(coordinator, description, forecast_day=day)
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
for description in FORECAST_SENSOR_TYPES
|
||||
if description.key in coordinator.data[ATTR_FORECAST][0]
|
||||
@@ -413,34 +383,27 @@ class AccuWeatherSensor(
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.location_key}-{description.key}".lower()
|
||||
)
|
||||
self._attr_native_unit_of_measurement = description.native_unit_of_measurement
|
||||
if self.coordinator.hass.config.units is METRIC_SYSTEM:
|
||||
self._unit_system = API_METRIC
|
||||
if metric_unit := description.metric_unit:
|
||||
self._attr_native_unit_of_measurement = metric_unit
|
||||
else:
|
||||
self._unit_system = API_IMPERIAL
|
||||
if us_customary_unit := description.us_customary_unit:
|
||||
self._attr_native_unit_of_measurement = us_customary_unit
|
||||
self._attr_device_info = coordinator.device_info
|
||||
if forecast_day is not None:
|
||||
self.forecast_day = forecast_day
|
||||
self.forecast_day = forecast_day
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state."""
|
||||
return self.entity_description.value_fn(self._sensor_data, self._unit_system)
|
||||
return self.entity_description.value_fn(self._sensor_data)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
if self.forecast_day is not None:
|
||||
return self.entity_description.attr_fn(self._sensor_data)
|
||||
|
||||
return self.entity_description.attr_fn(self.coordinator.data)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle data update."""
|
||||
self._sensor_data = _get_sensor_data(
|
||||
self.coordinator.data, self.entity_description.key
|
||||
self.coordinator.data, self.entity_description.key, self.forecast_day
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -458,20 +421,3 @@ def _get_sensor_data(
|
||||
return sensors["PrecipitationSummary"]["PastHour"]
|
||||
|
||||
return sensors[kind]
|
||||
|
||||
|
||||
class AccuWeatherForecastSensor(AccuWeatherSensor):
|
||||
"""Define an AccuWeather forecast entity."""
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
return self.entity_description.attr_fn(self._sensor_data)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle data update."""
|
||||
self._sensor_data = _get_sensor_data(
|
||||
self.coordinator.data, self.entity_description.key, self.forecast_day
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -28,17 +28,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util.dt import utc_from_timestamp
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from . import AccuWeatherDataUpdateCoordinator
|
||||
from .const import (
|
||||
API_IMPERIAL,
|
||||
API_METRIC,
|
||||
ATTR_FORECAST,
|
||||
ATTRIBUTION,
|
||||
CONDITION_CLASSES,
|
||||
DOMAIN,
|
||||
)
|
||||
from .const import API_METRIC, ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, DOMAIN
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -66,20 +58,11 @@ class AccuWeatherEntity(
|
||||
# Coordinator data is used also for sensors which don't have units automatically
|
||||
# converted, hence the weather entity's native units follow the configured unit
|
||||
# system
|
||||
if coordinator.hass.config.units is METRIC_SYSTEM:
|
||||
self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
|
||||
self._attr_native_pressure_unit = UnitOfPressure.HPA
|
||||
self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
self._attr_native_visibility_unit = UnitOfLength.KILOMETERS
|
||||
self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
|
||||
self._unit_system = API_METRIC
|
||||
else:
|
||||
self._unit_system = API_IMPERIAL
|
||||
self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.INCHES
|
||||
self._attr_native_pressure_unit = UnitOfPressure.INHG
|
||||
self._attr_native_temperature_unit = UnitOfTemperature.FAHRENHEIT
|
||||
self._attr_native_visibility_unit = UnitOfLength.MILES
|
||||
self._attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR
|
||||
self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
|
||||
self._attr_native_pressure_unit = UnitOfPressure.HPA
|
||||
self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
self._attr_native_visibility_unit = UnitOfLength.KILOMETERS
|
||||
self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
|
||||
self._attr_unique_id = coordinator.location_key
|
||||
self._attr_attribution = ATTRIBUTION
|
||||
self._attr_device_info = coordinator.device_info
|
||||
@@ -99,16 +82,12 @@ class AccuWeatherEntity(
|
||||
@property
|
||||
def native_temperature(self) -> float:
|
||||
"""Return the temperature."""
|
||||
return cast(
|
||||
float, self.coordinator.data["Temperature"][self._unit_system]["Value"]
|
||||
)
|
||||
return cast(float, self.coordinator.data["Temperature"][API_METRIC]["Value"])
|
||||
|
||||
@property
|
||||
def native_pressure(self) -> float:
|
||||
"""Return the pressure."""
|
||||
return cast(
|
||||
float, self.coordinator.data["Pressure"][self._unit_system]["Value"]
|
||||
)
|
||||
return cast(float, self.coordinator.data["Pressure"][API_METRIC]["Value"])
|
||||
|
||||
@property
|
||||
def humidity(self) -> int:
|
||||
@@ -118,9 +97,7 @@ class AccuWeatherEntity(
|
||||
@property
|
||||
def native_wind_speed(self) -> float:
|
||||
"""Return the wind speed."""
|
||||
return cast(
|
||||
float, self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"]
|
||||
)
|
||||
return cast(float, self.coordinator.data["Wind"]["Speed"][API_METRIC]["Value"])
|
||||
|
||||
@property
|
||||
def wind_bearing(self) -> int:
|
||||
@@ -130,19 +107,7 @@ class AccuWeatherEntity(
|
||||
@property
|
||||
def native_visibility(self) -> float:
|
||||
"""Return the visibility."""
|
||||
return cast(
|
||||
float, self.coordinator.data["Visibility"][self._unit_system]["Value"]
|
||||
)
|
||||
|
||||
@property
|
||||
def ozone(self) -> int | None:
|
||||
"""Return the ozone level."""
|
||||
# We only have ozone data for certain locations and only in the forecast data.
|
||||
if self.coordinator.forecast and self.coordinator.data[ATTR_FORECAST][0].get(
|
||||
"Ozone"
|
||||
):
|
||||
return cast(int, self.coordinator.data[ATTR_FORECAST][0]["Ozone"]["Value"])
|
||||
return None
|
||||
return cast(float, self.coordinator.data["Visibility"][API_METRIC]["Value"])
|
||||
|
||||
@property
|
||||
def forecast(self) -> list[Forecast] | None:
|
||||
|
||||
@@ -7,11 +7,11 @@ from advantage_air import ApiError, advantage_air
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import ADVANTAGE_AIR_RETRY, DOMAIN
|
||||
from .models import AdvantageAirData
|
||||
|
||||
ADVANTAGE_AIR_SYNC_INTERVAL = 15
|
||||
PLATFORMS = [
|
||||
@@ -53,29 +53,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL),
|
||||
)
|
||||
|
||||
def error_handle_factory(func):
|
||||
"""Return the provided API function wrapped.
|
||||
|
||||
Adds an error handler and coordinator refresh.
|
||||
"""
|
||||
|
||||
async def error_handle(param):
|
||||
try:
|
||||
if await func(param):
|
||||
await coordinator.async_refresh()
|
||||
except ApiError as err:
|
||||
raise HomeAssistantError(err) from err
|
||||
|
||||
return error_handle
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
"coordinator": coordinator,
|
||||
"aircon": error_handle_factory(api.aircon.async_set),
|
||||
"lights": error_handle_factory(api.lights.async_set),
|
||||
}
|
||||
hass.data[DOMAIN][entry.entry_id] = AdvantageAirData(coordinator, api)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Binary Sensor platform for Advantage Air integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
@@ -14,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN
|
||||
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -25,10 +24,10 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir Binary Sensor platform."""
|
||||
|
||||
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities: list[BinarySensorEntity] = []
|
||||
if aircons := instance["coordinator"].data.get("aircons"):
|
||||
if aircons := instance.coordinator.data.get("aircons"):
|
||||
for ac_key, ac_device in aircons.items():
|
||||
entities.append(AdvantageAirFilter(instance, ac_key))
|
||||
for zone_key, zone in ac_device["zones"].items():
|
||||
@@ -48,7 +47,7 @@ class AdvantageAirFilter(AdvantageAirAcEntity, BinarySensorEntity):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_name = "Filter"
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
"""Initialize an Advantage Air Filter sensor."""
|
||||
super().__init__(instance, ac_key)
|
||||
self._attr_unique_id += "-filter"
|
||||
@@ -64,7 +63,7 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity):
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.MOTION
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
"""Initialize an Advantage Air Zone Motion sensor."""
|
||||
super().__init__(instance, ac_key, zone_key)
|
||||
self._attr_name = f'{self._zone["name"]} motion'
|
||||
@@ -82,7 +81,7 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity):
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
"""Initialize an Advantage Air Zone MyZone sensor."""
|
||||
super().__init__(instance, ac_key, zone_key)
|
||||
self._attr_name = f'{self._zone["name"]} myZone'
|
||||
|
||||
@@ -5,6 +5,8 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_TARGET_TEMP_HIGH,
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
FAN_AUTO,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
@@ -26,24 +28,17 @@ from .const import (
|
||||
DOMAIN as ADVANTAGE_AIR_DOMAIN,
|
||||
)
|
||||
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
ADVANTAGE_AIR_HVAC_MODES = {
|
||||
"heat": HVACMode.HEAT,
|
||||
"cool": HVACMode.COOL,
|
||||
"vent": HVACMode.FAN_ONLY,
|
||||
"dry": HVACMode.DRY,
|
||||
"myauto": HVACMode.AUTO,
|
||||
"myauto": HVACMode.HEAT_COOL,
|
||||
}
|
||||
HASS_HVAC_MODES = {v: k for k, v in ADVANTAGE_AIR_HVAC_MODES.items()}
|
||||
|
||||
AC_HVAC_MODES = [
|
||||
HVACMode.OFF,
|
||||
HVACMode.COOL,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.DRY,
|
||||
]
|
||||
|
||||
ADVANTAGE_AIR_FAN_MODES = {
|
||||
"autoAA": FAN_AUTO,
|
||||
"low": FAN_LOW,
|
||||
@@ -53,7 +48,14 @@ ADVANTAGE_AIR_FAN_MODES = {
|
||||
HASS_FAN_MODES = {v: k for k, v in ADVANTAGE_AIR_FAN_MODES.items()}
|
||||
FAN_SPEEDS = {FAN_LOW: 30, FAN_MEDIUM: 60, FAN_HIGH: 100}
|
||||
|
||||
ZONE_HVAC_MODES = [HVACMode.OFF, HVACMode.HEAT_COOL]
|
||||
ADVANTAGE_AIR_AUTOFAN = "aaAutoFanModeEnabled"
|
||||
ADVANTAGE_AIR_MYZONE = "MyZone"
|
||||
ADVANTAGE_AIR_MYAUTO = "MyAuto"
|
||||
ADVANTAGE_AIR_MYAUTO_ENABLED = "myAutoModeEnabled"
|
||||
ADVANTAGE_AIR_MYTEMP = "MyTemp"
|
||||
ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled"
|
||||
ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp"
|
||||
ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp"
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -67,15 +69,15 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir climate platform."""
|
||||
|
||||
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities: list[ClimateEntity] = []
|
||||
if aircons := instance["coordinator"].data.get("aircons"):
|
||||
if aircons := instance.coordinator.data.get("aircons"):
|
||||
for ac_key, ac_device in aircons.items():
|
||||
entities.append(AdvantageAirAC(instance, ac_key))
|
||||
for zone_key, zone in ac_device["zones"].items():
|
||||
# Only add zone climate control when zone is in temperature control
|
||||
if zone["type"] != 0:
|
||||
if zone["type"] > 0:
|
||||
entities.append(AdvantageAirZone(instance, ac_key, zone_key))
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -83,24 +85,56 @@ async def async_setup_entry(
|
||||
class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
|
||||
"""AdvantageAir AC unit."""
|
||||
|
||||
_attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH]
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_target_temperature_step = PRECISION_WHOLE
|
||||
_attr_max_temp = 32
|
||||
_attr_min_temp = 16
|
||||
_attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH]
|
||||
_attr_hvac_modes = AC_HVAC_MODES
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
|
||||
)
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
"""Initialize an AdvantageAir AC unit."""
|
||||
super().__init__(instance, ac_key)
|
||||
if self._ac.get("myAutoModeEnabled"):
|
||||
self._attr_hvac_modes = AC_HVAC_MODES + [HVACMode.AUTO]
|
||||
|
||||
# Set supported features and HVAC modes based on current operating mode
|
||||
if self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED):
|
||||
# MyAuto
|
||||
self._attr_supported_features = (
|
||||
ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
)
|
||||
self._attr_hvac_modes = [
|
||||
HVACMode.OFF,
|
||||
HVACMode.COOL,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.DRY,
|
||||
HVACMode.HEAT_COOL,
|
||||
]
|
||||
elif self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED):
|
||||
# MyTemp
|
||||
self._attr_supported_features = ClimateEntityFeature.FAN_MODE
|
||||
self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT]
|
||||
|
||||
else:
|
||||
# MyZone
|
||||
self._attr_supported_features = (
|
||||
ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
)
|
||||
self._attr_hvac_modes = [
|
||||
HVACMode.OFF,
|
||||
HVACMode.COOL,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.DRY,
|
||||
]
|
||||
|
||||
# Add "ezfan" mode if supported
|
||||
if self._ac.get(ADVANTAGE_AIR_AUTOFAN):
|
||||
self._attr_fan_modes += [FAN_AUTO]
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the current target temperature."""
|
||||
return self._ac["setTemp"]
|
||||
|
||||
@@ -116,77 +150,71 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
|
||||
"""Return the current fan modes."""
|
||||
return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"])
|
||||
|
||||
@property
|
||||
def target_temperature_high(self) -> float | None:
|
||||
"""Return the temperature cool mode is enabled."""
|
||||
return self._ac.get(ADVANTAGE_AIR_COOL_TARGET)
|
||||
|
||||
@property
|
||||
def target_temperature_low(self) -> float | None:
|
||||
"""Return the temperature heat mode is enabled."""
|
||||
return self._ac.get(ADVANTAGE_AIR_HEAT_TARGET)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Set the HVAC State to on."""
|
||||
await self.aircon(
|
||||
{
|
||||
self.ac_key: {
|
||||
"info": {
|
||||
"state": ADVANTAGE_AIR_STATE_ON,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
await self.async_update_ac({"state": ADVANTAGE_AIR_STATE_ON})
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Set the HVAC State to off."""
|
||||
await self.aircon(
|
||||
await self.async_update_ac(
|
||||
{
|
||||
self.ac_key: {
|
||||
"info": {
|
||||
"state": ADVANTAGE_AIR_STATE_OFF,
|
||||
}
|
||||
}
|
||||
"state": ADVANTAGE_AIR_STATE_OFF,
|
||||
}
|
||||
)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC Mode and State."""
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self.aircon(
|
||||
{self.ac_key: {"info": {"state": ADVANTAGE_AIR_STATE_OFF}}}
|
||||
)
|
||||
await self.async_update_ac({"state": ADVANTAGE_AIR_STATE_OFF})
|
||||
else:
|
||||
await self.aircon(
|
||||
await self.async_update_ac(
|
||||
{
|
||||
self.ac_key: {
|
||||
"info": {
|
||||
"state": ADVANTAGE_AIR_STATE_ON,
|
||||
"mode": HASS_HVAC_MODES.get(hvac_mode),
|
||||
}
|
||||
}
|
||||
"state": ADVANTAGE_AIR_STATE_ON,
|
||||
"mode": HASS_HVAC_MODES.get(hvac_mode),
|
||||
}
|
||||
)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set the Fan Mode."""
|
||||
await self.aircon(
|
||||
{self.ac_key: {"info": {"fan": HASS_FAN_MODES.get(fan_mode)}}}
|
||||
)
|
||||
await self.async_update_ac({"fan": HASS_FAN_MODES.get(fan_mode)})
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the Temperature."""
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
await self.aircon({self.ac_key: {"info": {"setTemp": temp}}})
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
await self.async_update_ac({"setTemp": kwargs[ATTR_TEMPERATURE]})
|
||||
if ATTR_TARGET_TEMP_LOW in kwargs and ATTR_TARGET_TEMP_HIGH in kwargs:
|
||||
await self.async_update_ac(
|
||||
{
|
||||
ADVANTAGE_AIR_COOL_TARGET: kwargs[ATTR_TARGET_TEMP_HIGH],
|
||||
ADVANTAGE_AIR_HEAT_TARGET: kwargs[ATTR_TARGET_TEMP_LOW],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
|
||||
"""AdvantageAir Zone control."""
|
||||
"""AdvantageAir MyTemp Zone control."""
|
||||
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT_COOL]
|
||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_target_temperature_step = PRECISION_WHOLE
|
||||
_attr_max_temp = 32
|
||||
_attr_min_temp = 16
|
||||
_attr_hvac_modes = ZONE_HVAC_MODES
|
||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
"""Initialize an AdvantageAir Zone control."""
|
||||
super().__init__(instance, ac_key, zone_key)
|
||||
self._attr_name = self._zone["name"]
|
||||
self._attr_unique_id = (
|
||||
f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}'
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
@@ -196,7 +224,7 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
|
||||
return HVACMode.OFF
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._zone["measuredTemp"]
|
||||
|
||||
@@ -207,23 +235,11 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Set the HVAC State to on."""
|
||||
await self.aircon(
|
||||
{
|
||||
self.ac_key: {
|
||||
"zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN}}
|
||||
}
|
||||
}
|
||||
)
|
||||
await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_OPEN})
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Set the HVAC State to off."""
|
||||
await self.aircon(
|
||||
{
|
||||
self.ac_key: {
|
||||
"zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}}
|
||||
}
|
||||
}
|
||||
)
|
||||
await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_CLOSE})
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC Mode and State."""
|
||||
@@ -235,4 +251,4 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the Temperature."""
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
await self.aircon({self.ac_key: {"zones": {self.zone_key: {"setTemp": temp}}}})
|
||||
await self.async_update_zone({"setTemp": temp})
|
||||
|
||||
@@ -16,7 +16,8 @@ from .const import (
|
||||
ADVANTAGE_AIR_STATE_OPEN,
|
||||
DOMAIN as ADVANTAGE_AIR_DOMAIN,
|
||||
)
|
||||
from .entity import AdvantageAirZoneEntity
|
||||
from .entity import AdvantageAirThingEntity, AdvantageAirZoneEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -28,15 +29,25 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir cover platform."""
|
||||
|
||||
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities: list[CoverEntity] = []
|
||||
if aircons := instance["coordinator"].data.get("aircons"):
|
||||
if aircons := instance.coordinator.data.get("aircons"):
|
||||
for ac_key, ac_device in aircons.items():
|
||||
for zone_key, zone in ac_device["zones"].items():
|
||||
# Only add zone vent controls when zone in vent control mode.
|
||||
if zone["type"] == 0:
|
||||
entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key))
|
||||
if things := instance.coordinator.data.get("myThings"):
|
||||
for thing in things["things"].values():
|
||||
if thing["channelDipState"] in [1, 2]: # 1 = "Blind", 2 = "Blind 2"
|
||||
entities.append(
|
||||
AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND)
|
||||
)
|
||||
elif thing["channelDipState"] == 3: # 3 = "Garage door"
|
||||
entities.append(
|
||||
AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -50,7 +61,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity):
|
||||
| CoverEntityFeature.SET_POSITION
|
||||
)
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
"""Initialize an Advantage Air Zone Vent."""
|
||||
super().__init__(instance, ac_key, zone_key)
|
||||
self._attr_name = self._zone["name"]
|
||||
@@ -69,47 +80,52 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity):
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully open zone vent."""
|
||||
await self.aircon(
|
||||
{
|
||||
self.ac_key: {
|
||||
"zones": {
|
||||
self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN, "value": 100}
|
||||
}
|
||||
}
|
||||
}
|
||||
await self.async_update_zone(
|
||||
{"state": ADVANTAGE_AIR_STATE_OPEN, "value": 100},
|
||||
)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully close zone vent."""
|
||||
await self.aircon(
|
||||
{
|
||||
self.ac_key: {
|
||||
"zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}}
|
||||
}
|
||||
}
|
||||
)
|
||||
await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_CLOSE})
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Change vent position."""
|
||||
position = round(kwargs[ATTR_POSITION] / 5) * 5
|
||||
if position == 0:
|
||||
await self.aircon(
|
||||
{
|
||||
self.ac_key: {
|
||||
"zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}}
|
||||
}
|
||||
}
|
||||
)
|
||||
await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_CLOSE})
|
||||
else:
|
||||
await self.aircon(
|
||||
await self.async_update_zone(
|
||||
{
|
||||
self.ac_key: {
|
||||
"zones": {
|
||||
self.zone_key: {
|
||||
"state": ADVANTAGE_AIR_STATE_OPEN,
|
||||
"value": position,
|
||||
}
|
||||
}
|
||||
}
|
||||
"state": ADVANTAGE_AIR_STATE_OPEN,
|
||||
"value": position,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdvantageAirThingCover(AdvantageAirThingEntity, CoverEntity):
|
||||
"""Representation of Advantage Air Cover controlled by MyPlace."""
|
||||
|
||||
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
instance: AdvantageAirData,
|
||||
thing: dict[str, Any],
|
||||
device_class: CoverDeviceClass,
|
||||
) -> None:
|
||||
"""Initialize an Advantage Air Things Cover."""
|
||||
super().__init__(instance, thing)
|
||||
self._attr_device_class = device_class
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
"""Return if cover is fully closed."""
|
||||
return self._data["value"] == 0
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully open zone vent."""
|
||||
return await self.async_turn_on()
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully close zone vent."""
|
||||
return await self.async_turn_off()
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Advantage Air parent entity class."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from advantage_air import ApiError
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .models import AdvantageAirData
|
||||
|
||||
|
||||
class AdvantageAirEntity(CoordinatorEntity):
|
||||
@@ -13,19 +16,34 @@ class AdvantageAirEntity(CoordinatorEntity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, instance: dict[str, Any]) -> None:
|
||||
def __init__(self, instance: AdvantageAirData) -> None:
|
||||
"""Initialize common aspects of an Advantage Air entity."""
|
||||
super().__init__(instance["coordinator"])
|
||||
super().__init__(instance.coordinator)
|
||||
self._attr_unique_id: str = self.coordinator.data["system"]["rid"]
|
||||
|
||||
def update_handle_factory(self, func, *keys):
|
||||
"""Return the provided API function wrapped.
|
||||
|
||||
Adds an error handler and coordinator refresh, and presets keys.
|
||||
"""
|
||||
|
||||
async def update_handle(*values):
|
||||
try:
|
||||
if await func(*keys, *values):
|
||||
await self.coordinator.async_refresh()
|
||||
except ApiError as err:
|
||||
raise HomeAssistantError(err) from err
|
||||
|
||||
return update_handle
|
||||
|
||||
|
||||
class AdvantageAirAcEntity(AdvantageAirEntity):
|
||||
"""Parent class for Advantage Air AC Entities."""
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
"""Initialize common aspects of an Advantage Air ac entity."""
|
||||
super().__init__(instance)
|
||||
self.aircon = instance["aircon"]
|
||||
|
||||
self.ac_key: str = ac_key
|
||||
self._attr_unique_id += f"-{ac_key}"
|
||||
|
||||
@@ -36,6 +54,9 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
|
||||
model=self.coordinator.data["system"]["sysType"],
|
||||
name=self.coordinator.data["aircons"][self.ac_key]["info"]["name"],
|
||||
)
|
||||
self.async_update_ac = self.update_handle_factory(
|
||||
instance.api.aircon.async_update_ac, self.ac_key
|
||||
)
|
||||
|
||||
@property
|
||||
def _ac(self) -> dict[str, Any]:
|
||||
@@ -45,12 +66,56 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
|
||||
class AdvantageAirZoneEntity(AdvantageAirAcEntity):
|
||||
"""Parent class for Advantage Air Zone Entities."""
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
"""Initialize common aspects of an Advantage Air zone entity."""
|
||||
super().__init__(instance, ac_key)
|
||||
|
||||
self.zone_key: str = zone_key
|
||||
self._attr_unique_id += f"-{zone_key}"
|
||||
self.async_update_zone = self.update_handle_factory(
|
||||
instance.api.aircon.async_update_zone, self.ac_key, self.zone_key
|
||||
)
|
||||
|
||||
@property
|
||||
def _zone(self) -> dict[str, Any]:
|
||||
return self.coordinator.data["aircons"][self.ac_key]["zones"][self.zone_key]
|
||||
|
||||
|
||||
class AdvantageAirThingEntity(AdvantageAirEntity):
|
||||
"""Parent class for Advantage Air Things Entities."""
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, thing: dict[str, Any]) -> None:
|
||||
"""Initialize common aspects of an Advantage Air Things entity."""
|
||||
super().__init__(instance)
|
||||
|
||||
self._id = thing["id"]
|
||||
self._attr_unique_id += f"-{self._id}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
via_device=(DOMAIN, self.coordinator.data["system"]["rid"]),
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
manufacturer="Advantage Air",
|
||||
model="MyPlace",
|
||||
name=thing["name"],
|
||||
)
|
||||
self.async_update_value = self.update_handle_factory(
|
||||
instance.api.things.async_update_value, self._id
|
||||
)
|
||||
|
||||
@property
|
||||
def _data(self) -> dict:
|
||||
"""Return the thing data."""
|
||||
return self.coordinator.data["myThings"]["things"][self._id]
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return if the thing is considered on."""
|
||||
return self._data["value"] > 0
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the thing on."""
|
||||
await self.async_update_value(True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the thing off."""
|
||||
await self.async_update_value(False)
|
||||
|
||||
@@ -7,12 +7,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
ADVANTAGE_AIR_STATE_OFF,
|
||||
ADVANTAGE_AIR_STATE_ON,
|
||||
DOMAIN as ADVANTAGE_AIR_DOMAIN,
|
||||
)
|
||||
from .entity import AdvantageAirEntity
|
||||
from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN
|
||||
from .entity import AdvantageAirEntity, AdvantageAirThingEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -22,15 +19,21 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir light platform."""
|
||||
|
||||
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities: list[LightEntity] = []
|
||||
if my_lights := instance["coordinator"].data.get("myLights"):
|
||||
if my_lights := instance.coordinator.data.get("myLights"):
|
||||
for light in my_lights["lights"].values():
|
||||
if light.get("relay"):
|
||||
entities.append(AdvantageAirLight(instance, light))
|
||||
else:
|
||||
entities.append(AdvantageAirLightDimmable(instance, light))
|
||||
if things := instance.coordinator.data.get("myThings"):
|
||||
for thing in things["things"].values():
|
||||
if thing["channelDipState"] == 4: # 4 = "Light (on/off)""
|
||||
entities.append(AdvantageAirThingLight(instance, thing))
|
||||
elif thing["channelDipState"] == 5: # 5 = "Light (Dimmable)""
|
||||
entities.append(AdvantageAirThingLightDimmable(instance, thing))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -39,10 +42,10 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
|
||||
def __init__(self, instance: dict[str, Any], light: dict[str, Any]) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None:
|
||||
"""Initialize an Advantage Air Light."""
|
||||
super().__init__(instance)
|
||||
self.lights = instance["lights"]
|
||||
|
||||
self._id: str = light["id"]
|
||||
self._attr_unique_id += f"-{self._id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
@@ -52,24 +55,27 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
|
||||
model=light.get("moduleType"),
|
||||
name=light["name"],
|
||||
)
|
||||
self.async_update_state = self.update_handle_factory(
|
||||
instance.api.lights.async_update_state, self._id
|
||||
)
|
||||
|
||||
@property
|
||||
def _light(self) -> dict[str, Any]:
|
||||
def _data(self) -> dict[str, Any]:
|
||||
"""Return the light object."""
|
||||
return self.coordinator.data["myLights"]["lights"][self._id]
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return if the light is on."""
|
||||
return self._light["state"] == ADVANTAGE_AIR_STATE_ON
|
||||
return self._data["state"] == ADVANTAGE_AIR_STATE_ON
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
await self.lights({"id": self._id, "state": ADVANTAGE_AIR_STATE_ON})
|
||||
await self.async_update_state(True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self.lights({"id": self._id, "state": ADVANTAGE_AIR_STATE_OFF})
|
||||
await self.async_update_state(False)
|
||||
|
||||
|
||||
class AdvantageAirLightDimmable(AdvantageAirLight):
|
||||
@@ -77,14 +83,41 @@ class AdvantageAirLightDimmable(AdvantageAirLight):
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS}
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None:
|
||||
"""Initialize an Advantage Air Dimmable Light."""
|
||||
super().__init__(instance, light)
|
||||
self.async_update_value = self.update_handle_factory(
|
||||
instance.api.lights.async_update_value, self._id
|
||||
)
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return round(self._light["value"] * 255 / 100)
|
||||
return round(self._data["value"] * 255 / 100)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on and optionally set the brightness."""
|
||||
data: dict[str, Any] = {"id": self._id, "state": ADVANTAGE_AIR_STATE_ON}
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
data["value"] = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255)
|
||||
await self.lights(data)
|
||||
return await self.async_update_value(round(kwargs[ATTR_BRIGHTNESS] / 2.55))
|
||||
return await self.async_update_state(True)
|
||||
|
||||
|
||||
class AdvantageAirThingLight(AdvantageAirThingEntity, LightEntity):
|
||||
"""Representation of Advantage Air Light controlled by myThings."""
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
|
||||
|
||||
class AdvantageAirThingLightDimmable(AdvantageAirThingEntity, LightEntity):
|
||||
"""Representation of Advantage Air Dimmable Light controlled by myThings."""
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS}
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return round(self._data["value"] * 255 / 100)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on by setting the brightness."""
|
||||
await self.async_update_value(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55))
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["advantage_air"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["advantage_air==0.4.1"]
|
||||
"requirements": ["advantage_air==0.4.4"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""The Advantage Air integration models."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from advantage_air import advantage_air
|
||||
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdvantageAirData:
|
||||
"""Data for the Advantage Air integration."""
|
||||
|
||||
coordinator: DataUpdateCoordinator
|
||||
api: advantage_air
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Select platform for Advantage Air integration."""
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -8,6 +7,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN
|
||||
from .entity import AdvantageAirAcEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
ADVANTAGE_AIR_INACTIVE = "Inactive"
|
||||
|
||||
@@ -19,10 +19,10 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir select platform."""
|
||||
|
||||
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities: list[SelectEntity] = []
|
||||
if aircons := instance["coordinator"].data.get("aircons"):
|
||||
if aircons := instance.coordinator.data.get("aircons"):
|
||||
for ac_key in aircons:
|
||||
entities.append(AdvantageAirMyZone(instance, ac_key))
|
||||
async_add_entities(entities)
|
||||
@@ -34,7 +34,7 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity):
|
||||
_attr_icon = "mdi:home-thermometer"
|
||||
_attr_name = "MyZone"
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
"""Initialize an Advantage Air MyZone control."""
|
||||
super().__init__(instance, ac_key)
|
||||
self._attr_unique_id += "-myzone"
|
||||
@@ -42,11 +42,12 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity):
|
||||
self._number_to_name = {0: ADVANTAGE_AIR_INACTIVE}
|
||||
self._name_to_number = {ADVANTAGE_AIR_INACTIVE: 0}
|
||||
|
||||
for zone in instance["coordinator"].data["aircons"][ac_key]["zones"].values():
|
||||
if zone["type"] > 0:
|
||||
self._name_to_number[zone["name"]] = zone["number"]
|
||||
self._number_to_name[zone["number"]] = zone["name"]
|
||||
self._attr_options.append(zone["name"])
|
||||
if "aircons" in instance.coordinator.data:
|
||||
for zone in instance.coordinator.data["aircons"][ac_key]["zones"].values():
|
||||
if zone["type"] > 0:
|
||||
self._name_to_number[zone["name"]] = zone["number"]
|
||||
self._number_to_name[zone["number"]] = zone["name"]
|
||||
self._attr_options.append(zone["name"])
|
||||
|
||||
@property
|
||||
def current_option(self) -> str:
|
||||
@@ -55,6 +56,4 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity):
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the MyZone."""
|
||||
await self.aircon(
|
||||
{self.ac_key: {"info": {"myZone": self._name_to_number[option]}}}
|
||||
)
|
||||
await self.async_update_ac({"myZone": self._name_to_number[option]})
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import ADVANTAGE_AIR_STATE_OPEN, DOMAIN as ADVANTAGE_AIR_DOMAIN
|
||||
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
ADVANTAGE_AIR_SET_COUNTDOWN_VALUE = "minutes"
|
||||
ADVANTAGE_AIR_SET_COUNTDOWN_UNIT = "min"
|
||||
@@ -34,10 +35,10 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir sensor platform."""
|
||||
|
||||
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities: list[SensorEntity] = []
|
||||
if aircons := instance["coordinator"].data.get("aircons"):
|
||||
if aircons := instance.coordinator.data.get("aircons"):
|
||||
for ac_key, ac_device in aircons.items():
|
||||
entities.append(AdvantageAirTimeTo(instance, ac_key, "On"))
|
||||
entities.append(AdvantageAirTimeTo(instance, ac_key, "Off"))
|
||||
@@ -65,7 +66,7 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity):
|
||||
_attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str, action: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, action: str) -> None:
|
||||
"""Initialize the Advantage Air timer control."""
|
||||
super().__init__(instance, ac_key)
|
||||
self.action = action
|
||||
@@ -88,7 +89,7 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity):
|
||||
async def set_time_to(self, **kwargs: Any) -> None:
|
||||
"""Set the timer value."""
|
||||
value = min(720, max(0, int(kwargs[ADVANTAGE_AIR_SET_COUNTDOWN_VALUE])))
|
||||
await self.aircon({self.ac_key: {"info": {self._time_key: value}}})
|
||||
await self.async_update_ac({self._time_key: value})
|
||||
|
||||
|
||||
class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity):
|
||||
@@ -98,7 +99,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity):
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
"""Initialize an Advantage Air Zone Vent Sensor."""
|
||||
super().__init__(instance, ac_key, zone_key=zone_key)
|
||||
self._attr_name = f'{self._zone["name"]} vent'
|
||||
@@ -126,7 +127,7 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity):
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
"""Initialize an Advantage Air Zone wireless signal sensor."""
|
||||
super().__init__(instance, ac_key, zone_key)
|
||||
self._attr_name = f'{self._zone["name"]} signal'
|
||||
@@ -160,7 +161,7 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity):
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
"""Initialize an Advantage Air Zone Temp Sensor."""
|
||||
super().__init__(instance, ac_key, zone_key)
|
||||
self._attr_name = f'{self._zone["name"]} temperature'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Switch platform for Advantage Air integration."""
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -11,7 +11,8 @@ from .const import (
|
||||
ADVANTAGE_AIR_STATE_ON,
|
||||
DOMAIN as ADVANTAGE_AIR_DOMAIN,
|
||||
)
|
||||
from .entity import AdvantageAirAcEntity
|
||||
from .entity import AdvantageAirAcEntity, AdvantageAirThingEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -21,13 +22,17 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir switch platform."""
|
||||
|
||||
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities: list[SwitchEntity] = []
|
||||
if aircons := instance["coordinator"].data.get("aircons"):
|
||||
if aircons := instance.coordinator.data.get("aircons"):
|
||||
for ac_key, ac_device in aircons.items():
|
||||
if ac_device["info"]["freshAirStatus"] != "none":
|
||||
entities.append(AdvantageAirFreshAir(instance, ac_key))
|
||||
if things := instance.coordinator.data.get("myThings"):
|
||||
for thing in things["things"].values():
|
||||
if thing["channelDipState"] == 8: # 8 = Other relay
|
||||
entities.append(AdvantageAirRelay(instance, thing))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -36,8 +41,9 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity):
|
||||
|
||||
_attr_icon = "mdi:air-filter"
|
||||
_attr_name = "Fresh air"
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
|
||||
def __init__(self, instance: dict[str, Any], ac_key: str) -> None:
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
"""Initialize an Advantage Air fresh air control."""
|
||||
super().__init__(instance, ac_key)
|
||||
self._attr_unique_id += "-freshair"
|
||||
@@ -49,12 +55,14 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn fresh air on."""
|
||||
await self.aircon(
|
||||
{self.ac_key: {"info": {"freshAirStatus": ADVANTAGE_AIR_STATE_ON}}}
|
||||
)
|
||||
await self.async_update_ac({"freshAirStatus": ADVANTAGE_AIR_STATE_ON})
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn fresh air off."""
|
||||
await self.aircon(
|
||||
{self.ac_key: {"info": {"freshAirStatus": ADVANTAGE_AIR_STATE_OFF}}}
|
||||
)
|
||||
await self.async_update_ac({"freshAirStatus": ADVANTAGE_AIR_STATE_OFF})
|
||||
|
||||
|
||||
class AdvantageAirRelay(AdvantageAirThingEntity, SwitchEntity):
|
||||
"""Representation of Advantage Air Thing."""
|
||||
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Advantage Air Update platform."""
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.update import UpdateEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -9,6 +8,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN
|
||||
from .entity import AdvantageAirEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -18,7 +18,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir update platform."""
|
||||
|
||||
instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]
|
||||
|
||||
async_add_entities([AdvantageAirApp(instance)])
|
||||
|
||||
@@ -28,7 +28,7 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity):
|
||||
|
||||
_attr_name = "App"
|
||||
|
||||
def __init__(self, instance: dict[str, Any]) -> None:
|
||||
def __init__(self, instance: AdvantageAirData) -> None:
|
||||
"""Initialize the Advantage Air App."""
|
||||
super().__init__(instance)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
|
||||
@@ -68,7 +68,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_CAQI,
|
||||
icon="mdi:air-filter",
|
||||
name=ATTR_API_CAQI,
|
||||
translation_key="caqi",
|
||||
native_unit_of_measurement="CAQI",
|
||||
suggested_display_precision=0,
|
||||
attrs=lambda data: {
|
||||
@@ -80,7 +80,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_PM1,
|
||||
device_class=SensorDeviceClass.PM1,
|
||||
name="PM1.0",
|
||||
translation_key="pm1",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
@@ -88,7 +88,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_PM25,
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
name="PM2.5",
|
||||
translation_key="pm25",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
@@ -100,7 +100,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_PM10,
|
||||
device_class=SensorDeviceClass.PM10,
|
||||
name=ATTR_API_PM10,
|
||||
translation_key="pm10",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
@@ -112,7 +112,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_HUMIDITY,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
name=ATTR_API_HUMIDITY.capitalize(),
|
||||
translation_key="humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
@@ -120,7 +120,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_PRESSURE,
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
name=ATTR_API_PRESSURE.capitalize(),
|
||||
translation_key="pressure",
|
||||
native_unit_of_measurement=UnitOfPressure.HPA,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
@@ -128,14 +128,14 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_TEMPERATURE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
name=ATTR_API_TEMPERATURE.capitalize(),
|
||||
translation_key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_CO,
|
||||
name="Carbon monoxide",
|
||||
translation_key="co",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
@@ -147,7 +147,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_NO2,
|
||||
device_class=SensorDeviceClass.NITROGEN_DIOXIDE,
|
||||
name="Nitrogen dioxide",
|
||||
translation_key="no2",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
@@ -159,7 +159,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_SO2,
|
||||
device_class=SensorDeviceClass.SULPHUR_DIOXIDE,
|
||||
name="Sulphur dioxide",
|
||||
translation_key="so2",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
@@ -171,7 +171,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_O3,
|
||||
device_class=SensorDeviceClass.OZONE,
|
||||
name="Ozone",
|
||||
translation_key="o3",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
|
||||
@@ -26,5 +26,42 @@
|
||||
"requests_remaining": "Remaining allowed requests",
|
||||
"requests_per_day": "Allowed requests per day"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"caqi": {
|
||||
"name": "Common air quality index"
|
||||
},
|
||||
"pm1": {
|
||||
"name": "[%key:component::sensor::entity_component::pm1::name%]"
|
||||
},
|
||||
"pm25": {
|
||||
"name": "[%key:component::sensor::entity_component::pm25::name%]"
|
||||
},
|
||||
"pm10": {
|
||||
"name": "[%key:component::sensor::entity_component::pm10::name%]"
|
||||
},
|
||||
"humidity": {
|
||||
"name": "[%key:component::sensor::entity_component::humidity::name%]"
|
||||
},
|
||||
"pressure": {
|
||||
"name": "[%key:component::sensor::entity_component::pressure::name%]"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "[%key:component::sensor::entity_component::temperature::name%]"
|
||||
},
|
||||
"co": {
|
||||
"name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]"
|
||||
},
|
||||
"no2": {
|
||||
"name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]"
|
||||
},
|
||||
"so2": {
|
||||
"name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
|
||||
},
|
||||
"o3": {
|
||||
"name": "[%key:component::sensor::entity_component::ozone::name%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,7 +380,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
else:
|
||||
entry.version = version
|
||||
hass.config_entries.async_update_entry(entry)
|
||||
|
||||
LOGGER.info("Migration to version %s successful", version)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from homeassistant.const import (
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.core import Event, HassJob, HomeAssistant
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
@@ -237,7 +237,13 @@ class Alert(Entity):
|
||||
"""Schedule a notification."""
|
||||
delay = self._delay[self._next_delay]
|
||||
next_msg = now() + delay
|
||||
self._cancel = async_track_point_in_time(self.hass, self._notify, next_msg)
|
||||
self._cancel = async_track_point_in_time(
|
||||
self.hass,
|
||||
HassJob(
|
||||
self._notify, name="Schedule notify alert", cancel_on_shutdown=True
|
||||
),
|
||||
next_msg,
|
||||
)
|
||||
self._next_delay = min(self._next_delay + 1, len(self._delay) - 1)
|
||||
|
||||
async def _notify(self, *args: Any) -> None:
|
||||
|
||||
@@ -117,7 +117,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
en_reg.async_clear_config_entry(entry.entry_id)
|
||||
|
||||
version = entry.version = 2
|
||||
hass.config_entries.async_update_entry(entry)
|
||||
|
||||
LOGGER.info("Migration to version %s successful", version)
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioambient"],
|
||||
"requirements": ["aioambient==2021.11.0"]
|
||||
"requirements": ["aioambient==2023.04.0"]
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from .const import ATTRIBUTION, CONF_STATION_ID, SCAN_INTERVAL
|
||||
from .const import CONF_STATION_ID, SCAN_INTERVAL
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,6 +54,8 @@ async def async_setup_platform(
|
||||
class AmpioSmogQuality(AirQualityEntity):
|
||||
"""Implementation of an Ampio Smog air quality entity."""
|
||||
|
||||
_attr_attribution = "Data provided by Ampio"
|
||||
|
||||
def __init__(
|
||||
self, api: AmpioSmogMapData, station_id: str, name: str | None
|
||||
) -> None:
|
||||
@@ -82,11 +84,6 @@ class AmpioSmogQuality(AirQualityEntity):
|
||||
"""Return the particulate matter 10 level."""
|
||||
return self._ampio.api.pm10 # type: ignore[no-any-return]
|
||||
|
||||
@property
|
||||
def attribution(self) -> str:
|
||||
"""Return the attribution."""
|
||||
return ATTRIBUTION
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Get the latest data from the AmpioMap API."""
|
||||
await self._ampio.async_update()
|
||||
|
||||
@@ -2,6 +2,5 @@
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
ATTRIBUTION: Final = "Data provided by Ampio"
|
||||
CONF_STATION_ID: Final = "station_id"
|
||||
SCAN_INTERVAL: Final = timedelta(minutes=10)
|
||||
|
||||
@@ -5,7 +5,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.core import Event, HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -24,11 +24,23 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
|
||||
def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
# Wait 15 min after started
|
||||
async_call_later(hass, 900, analytics.send_analytics)
|
||||
async_call_later(
|
||||
hass,
|
||||
900,
|
||||
HassJob(
|
||||
analytics.send_analytics,
|
||||
name="analytics schedule",
|
||||
cancel_on_shutdown=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Send every day
|
||||
async_track_time_interval(
|
||||
hass, analytics.send_analytics, INTERVAL, "analytics daily"
|
||||
hass,
|
||||
analytics.send_analytics,
|
||||
INTERVAL,
|
||||
name="analytics daily",
|
||||
cancel_on_shutdown=True,
|
||||
)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Support for functionality to interact with Android TV/Fire TV devices."""
|
||||
"""Support for functionality to interact with Android/Fire TV devices."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
@@ -135,11 +135,11 @@ async def async_connect_androidtv(
|
||||
if not aftv.available:
|
||||
# Determine the name that will be used for the device in the log
|
||||
if config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV:
|
||||
device_name = "Android TV device"
|
||||
device_name = "Android device"
|
||||
elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV:
|
||||
device_name = "Fire TV device"
|
||||
else:
|
||||
device_name = "Android TV / Fire TV device"
|
||||
device_name = "Android / Fire TV device"
|
||||
|
||||
error_message = f"Could not connect to {device_name} at {address} {adb_log}"
|
||||
return None, error_message
|
||||
@@ -148,7 +148,7 @@ async def async_connect_androidtv(
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Android TV platform."""
|
||||
"""Set up Android Debug Bridge platform."""
|
||||
|
||||
state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES)
|
||||
if CONF_ADB_SERVER_IP not in entry.data:
|
||||
@@ -167,7 +167,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
raise ConfigEntryNotReady(error_message)
|
||||
|
||||
async def async_close_connection(event):
|
||||
"""Close Android TV connection on HA Stop."""
|
||||
"""Close Android Debug Bridge connection on HA Stop."""
|
||||
await aftv.adb_close()
|
||||
|
||||
entry.async_on_unload(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Config flow to configure the Android TV integration."""
|
||||
"""Config flow to configure the Android Debug Bridge integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -114,13 +114,14 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def _async_check_connection(
|
||||
self, user_input: dict[str, Any]
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Attempt to connect the Android TV."""
|
||||
"""Attempt to connect the Android device."""
|
||||
|
||||
try:
|
||||
aftv, error_message = await async_connect_androidtv(self.hass, user_input)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception(
|
||||
"Unknown error connecting with Android TV at %s", user_input[CONF_HOST]
|
||||
"Unknown error connecting with Android device at %s",
|
||||
user_input[CONF_HOST],
|
||||
)
|
||||
return RESULT_UNKNOWN, None
|
||||
|
||||
@@ -130,7 +131,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
dev_prop = aftv.device_properties
|
||||
_LOGGER.info(
|
||||
"Android TV at %s: %s = %r, %s = %r",
|
||||
"Android device at %s: %s = %r, %s = %r",
|
||||
user_input[CONF_HOST],
|
||||
PROP_ETHMAC,
|
||||
dev_prop.get(PROP_ETHMAC),
|
||||
@@ -184,7 +185,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
|
||||
class OptionsFlowHandler(OptionsFlowWithConfigEntry):
|
||||
"""Handle an option flow for Android TV."""
|
||||
"""Handle an option flow for Android Debug Bridge."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Android TV component constants."""
|
||||
"""Android Debug Bridge component constants."""
|
||||
DOMAIN = "androidtv"
|
||||
|
||||
ANDROID_DEV = DOMAIN
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "androidtv",
|
||||
"name": "Android TV",
|
||||
"name": "Android Debug Bridge",
|
||||
"codeowners": ["@JeffLIrion", "@ollo69"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/androidtv",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Support for functionality to interact with Android TV / Fire TV devices."""
|
||||
"""Support for functionality to interact with Android / Fire TV devices."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
@@ -87,7 +87,7 @@ async def async_setup_entry(
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Android TV entity."""
|
||||
"""Set up the Android Debug Bridge entity."""
|
||||
aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV]
|
||||
device_class = aftv.DEVICE_CLASS
|
||||
device_type = (
|
||||
@@ -201,7 +201,7 @@ def adb_decorator(
|
||||
|
||||
|
||||
class ADBDevice(MediaPlayerEntity):
|
||||
"""Representation of an Android TV or Fire TV device."""
|
||||
"""Representation of an Android or Fire TV device."""
|
||||
|
||||
_attr_device_class = MediaPlayerDeviceClass.TV
|
||||
|
||||
@@ -214,7 +214,7 @@ class ADBDevice(MediaPlayerEntity):
|
||||
entry_id,
|
||||
entry_data,
|
||||
):
|
||||
"""Initialize the Android TV / Fire TV device."""
|
||||
"""Initialize the Android / Fire TV device."""
|
||||
self.aftv = aftv
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = unique_id
|
||||
@@ -384,7 +384,7 @@ class ADBDevice(MediaPlayerEntity):
|
||||
|
||||
@adb_decorator()
|
||||
async def adb_command(self, command):
|
||||
"""Send an ADB command to an Android TV / Fire TV device."""
|
||||
"""Send an ADB command to an Android / Fire TV device."""
|
||||
if key := KEYS.get(command):
|
||||
await self.aftv.adb_shell(f"input keyevent {key}")
|
||||
return
|
||||
@@ -422,13 +422,13 @@ class ADBDevice(MediaPlayerEntity):
|
||||
persistent_notification.async_create(
|
||||
self.hass,
|
||||
msg,
|
||||
title="Android TV",
|
||||
title="Android Debug Bridge",
|
||||
)
|
||||
_LOGGER.info("%s", msg)
|
||||
|
||||
@adb_decorator()
|
||||
async def service_download(self, device_path, local_path):
|
||||
"""Download a file from your Android TV / Fire TV device to your Home Assistant instance."""
|
||||
"""Download a file from your Android / Fire TV device to your Home Assistant instance."""
|
||||
if not self.hass.config.is_allowed_path(local_path):
|
||||
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
|
||||
return
|
||||
@@ -437,7 +437,7 @@ class ADBDevice(MediaPlayerEntity):
|
||||
|
||||
@adb_decorator()
|
||||
async def service_upload(self, device_path, local_path):
|
||||
"""Upload a file from your Home Assistant instance to an Android TV / Fire TV device."""
|
||||
"""Upload a file from your Home Assistant instance to an Android / Fire TV device."""
|
||||
if not self.hass.config.is_allowed_path(local_path):
|
||||
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
|
||||
return
|
||||
@@ -446,7 +446,7 @@ class ADBDevice(MediaPlayerEntity):
|
||||
|
||||
|
||||
class AndroidTVDevice(ADBDevice):
|
||||
"""Representation of an Android TV device."""
|
||||
"""Representation of an Android device."""
|
||||
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.PAUSE
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Describes the format for available Android TV and Fire TV services
|
||||
# Describes the format for available Android and Fire TV services
|
||||
|
||||
adb_command:
|
||||
name: ADB command
|
||||
description: Send an ADB command to an Android TV / Fire TV device.
|
||||
description: Send an ADB command to an Android / Fire TV device.
|
||||
target:
|
||||
entity:
|
||||
integration: androidtv
|
||||
@@ -17,7 +17,7 @@ adb_command:
|
||||
text:
|
||||
download:
|
||||
name: Download
|
||||
description: Download a file from your Android TV / Fire TV device to your Home Assistant instance.
|
||||
description: Download a file from your Android / Fire TV device to your Home Assistant instance.
|
||||
target:
|
||||
entity:
|
||||
integration: androidtv
|
||||
@@ -25,7 +25,7 @@ download:
|
||||
fields:
|
||||
device_path:
|
||||
name: Device path
|
||||
description: The filepath on the Android TV / Fire TV device.
|
||||
description: The filepath on the Android / Fire TV device.
|
||||
required: true
|
||||
example: "/storage/emulated/0/Download/example.txt"
|
||||
selector:
|
||||
@@ -39,7 +39,7 @@ download:
|
||||
text:
|
||||
upload:
|
||||
name: Upload
|
||||
description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device.
|
||||
description: Upload a file from your Home Assistant instance to an Android / Fire TV device.
|
||||
target:
|
||||
entity:
|
||||
integration: androidtv
|
||||
@@ -47,7 +47,7 @@ upload:
|
||||
fields:
|
||||
device_path:
|
||||
name: Device path
|
||||
description: The filepath on the Android TV / Fire TV device.
|
||||
description: The filepath on the Android / Fire TV device.
|
||||
required: true
|
||||
example: "/storage/emulated/0/Download/example.txt"
|
||||
selector:
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"title": "Configure Android TV Apps",
|
||||
"title": "Configure Android Apps",
|
||||
"description": "Configure application id {app_id}",
|
||||
"data": {
|
||||
"app_name": "Application Name",
|
||||
@@ -47,7 +47,7 @@
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"title": "Configure Android TV state detection rules",
|
||||
"title": "Configure Android state detection rules",
|
||||
"description": "Configure detection rule for application id {rule_id}",
|
||||
"data": {
|
||||
"rule_id": "Application ID",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""The Android TV Remote integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from androidtvremote2 import (
|
||||
AndroidTVRemote,
|
||||
CannotConnect,
|
||||
ConnectionClosed,
|
||||
InvalidAuth,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
from .helpers import create_api
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.REMOTE]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Android TV Remote from a config entry."""
|
||||
|
||||
api = create_api(hass, entry.data[CONF_HOST])
|
||||
try:
|
||||
await api.async_connect()
|
||||
except InvalidAuth as exc:
|
||||
# The Android TV is hard reset or the certificate and key files were deleted.
|
||||
raise ConfigEntryAuthFailed from exc
|
||||
except (CannotConnect, ConnectionClosed) as exc:
|
||||
# The Android TV is network unreachable. Raise exception and let Home Assistant retry
|
||||
# later. If device gets a new IP address the zeroconf flow will update the config.
|
||||
raise ConfigEntryNotReady from exc
|
||||
|
||||
def reauth_needed() -> None:
|
||||
"""Start a reauth flow if Android TV is hard reset while reconnecting."""
|
||||
entry.async_start_reauth(hass)
|
||||
|
||||
# Start a task (canceled in disconnect) to keep reconnecting if device becomes
|
||||
# network unreachable. If device gets a new IP address the zeroconf flow will
|
||||
# update the config entry data and reload the config entry.
|
||||
api.keep_reconnecting(reauth_needed)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@callback
|
||||
def on_hass_stop(event) -> None:
|
||||
"""Stop push updates when hass stops."""
|
||||
api.disconnect()
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
api.disconnect()
|
||||
|
||||
return unload_ok
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Config flow for Android TV Remote integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from androidtvremote2 import (
|
||||
AndroidTVRemote,
|
||||
CannotConnect,
|
||||
ConnectionClosed,
|
||||
InvalidAuth,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
from .const import DOMAIN
|
||||
from .helpers import create_api
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("host"): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_PAIR_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("pin"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Android TV Remote."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a new AndroidTVRemoteConfigFlow."""
|
||||
self.api: AndroidTVRemote | None = None
|
||||
self.reauth_entry: config_entries.ConfigEntry | None = None
|
||||
self.host: str | None = None
|
||||
self.name: str | None = None
|
||||
self.mac: str | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self.host = user_input["host"]
|
||||
assert self.host
|
||||
api = create_api(self.hass, self.host)
|
||||
try:
|
||||
self.name, self.mac = await api.async_get_name_and_mac()
|
||||
assert self.mac
|
||||
await self.async_set_unique_id(format_mac(self.mac))
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
|
||||
return await self._async_start_pair()
|
||||
except (CannotConnect, ConnectionClosed):
|
||||
# Likely invalid IP address or device is network unreachable. Stay
|
||||
# in the user step allowing the user to enter a different host.
|
||||
errors["base"] = "cannot_connect"
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _async_start_pair(self) -> FlowResult:
|
||||
"""Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen."""
|
||||
assert self.host
|
||||
self.api = create_api(self.hass, self.host)
|
||||
await self.api.async_generate_cert_if_missing()
|
||||
await self.api.async_start_pairing()
|
||||
return await self.async_step_pair()
|
||||
|
||||
async def async_step_pair(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the pair step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
pin = user_input["pin"]
|
||||
assert self.api
|
||||
await self.api.async_finish_pairing(pin)
|
||||
if self.reauth_entry:
|
||||
await self.hass.config_entries.async_reload(
|
||||
self.reauth_entry.entry_id
|
||||
)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
assert self.name
|
||||
return self.async_create_entry(
|
||||
title=self.name,
|
||||
data={
|
||||
CONF_HOST: self.host,
|
||||
CONF_NAME: self.name,
|
||||
CONF_MAC: self.mac,
|
||||
},
|
||||
)
|
||||
except InvalidAuth:
|
||||
# Invalid PIN. Stay in the pair step allowing the user to enter
|
||||
# a different PIN.
|
||||
errors["base"] = "invalid_auth"
|
||||
except ConnectionClosed:
|
||||
# Either user canceled pairing on the Android TV itself (most common)
|
||||
# or device doesn't respond to the specified host (device was unplugged,
|
||||
# network was unplugged, or device got a new IP address).
|
||||
# Attempt to pair again.
|
||||
try:
|
||||
return await self._async_start_pair()
|
||||
except (CannotConnect, ConnectionClosed):
|
||||
# Device doesn't respond to the specified host. Abort.
|
||||
# If we are in the user flow we could go back to the user step to allow
|
||||
# them to enter a new IP address but we cannot do that for the zeroconf
|
||||
# flow. Simpler to abort for both flows.
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
return self.async_show_form(
|
||||
step_id="pair",
|
||||
data_schema=STEP_PAIR_DATA_SCHEMA,
|
||||
description_placeholders={CONF_NAME: self.name},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: zeroconf.ZeroconfServiceInfo
|
||||
) -> FlowResult:
|
||||
"""Handle zeroconf discovery."""
|
||||
self.host = discovery_info.host
|
||||
self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.")
|
||||
self.mac = discovery_info.properties.get("bt")
|
||||
assert self.mac
|
||||
await self.async_set_unique_id(format_mac(self.mac))
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: self.host, CONF_NAME: self.name}
|
||||
)
|
||||
self.context.update({"title_placeholders": {CONF_NAME: self.name}})
|
||||
return await self.async_step_zeroconf_confirm()
|
||||
|
||||
async def async_step_zeroconf_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initiated by zeroconf."""
|
||||
if user_input is not None:
|
||||
try:
|
||||
return await self._async_start_pair()
|
||||
except (CannotConnect, ConnectionClosed):
|
||||
# Device became network unreachable after discovery.
|
||||
# Abort and let discovery find it again later.
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
return self.async_show_form(
|
||||
step_id="zeroconf_confirm",
|
||||
description_placeholders={CONF_NAME: self.name},
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||
"""Handle configuration by re-auth."""
|
||||
self.host = entry_data[CONF_HOST]
|
||||
self.name = entry_data[CONF_NAME]
|
||||
self.mac = entry_data[CONF_MAC]
|
||||
self.reauth_entry = self.hass.config_entries.async_get_entry(
|
||||
self.context["entry_id"]
|
||||
)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
return await self._async_start_pair()
|
||||
except (CannotConnect, ConnectionClosed):
|
||||
# Device is network unreachable. Abort.
|
||||
errors["base"] = "cannot_connect"
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
description_placeholders={CONF_NAME: self.name},
|
||||
errors=errors,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Constants for the Android TV Remote integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "androidtv_remote"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Diagnostics support for Android TV Remote."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from androidtvremote2 import AndroidTVRemote
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
TO_REDACT = {CONF_HOST, CONF_MAC}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return async_redact_data(
|
||||
{
|
||||
"api_device_info": api.device_info,
|
||||
"config_entry_data": entry.data,
|
||||
},
|
||||
TO_REDACT,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Helper functions for Android TV Remote integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from androidtvremote2 import AndroidTVRemote
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import STORAGE_DIR
|
||||
|
||||
|
||||
def create_api(hass: HomeAssistant, host: str) -> AndroidTVRemote:
|
||||
"""Create an AndroidTVRemote instance."""
|
||||
return AndroidTVRemote(
|
||||
client_name="Home Assistant",
|
||||
certfile=hass.config.path(STORAGE_DIR, "androidtv_remote_cert.pem"),
|
||||
keyfile=hass.config.path(STORAGE_DIR, "androidtv_remote_key.pem"),
|
||||
host=host,
|
||||
loop=hass.loop,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "androidtv_remote",
|
||||
"name": "Android TV Remote",
|
||||
"codeowners": ["@tronikos"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/androidtv_remote",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["androidtvremote2"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["androidtvremote2==0.0.7"],
|
||||
"zeroconf": ["_androidtvremote2._tcp.local."]
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Remote control support for Android TV Remote."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
|
||||
|
||||
from homeassistant.components.remote import (
|
||||
ATTR_ACTIVITY,
|
||||
ATTR_DELAY_SECS,
|
||||
ATTR_HOLD_SECS,
|
||||
ATTR_NUM_REPEATS,
|
||||
DEFAULT_DELAY_SECS,
|
||||
DEFAULT_HOLD_SECS,
|
||||
DEFAULT_NUM_REPEATS,
|
||||
RemoteEntity,
|
||||
RemoteEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Android TV remote entity based on a config entry."""
|
||||
api: AndroidTVRemote = hass.data[DOMAIN][config_entry.entry_id]
|
||||
async_add_entities([AndroidTVRemoteEntity(api, config_entry)])
|
||||
|
||||
|
||||
class AndroidTVRemoteEntity(RemoteEntity):
|
||||
"""Representation of an Android TV Remote."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize device."""
|
||||
self._api = api
|
||||
self._host = config_entry.data[CONF_HOST]
|
||||
self._name = config_entry.data[CONF_NAME]
|
||||
self._attr_unique_id = config_entry.unique_id
|
||||
self._attr_supported_features = RemoteEntityFeature.ACTIVITY
|
||||
self._attr_is_on = api.is_on
|
||||
self._attr_current_activity = api.current_app
|
||||
device_info = api.device_info
|
||||
assert config_entry.unique_id
|
||||
assert device_info
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])},
|
||||
identifiers={(DOMAIN, config_entry.unique_id)},
|
||||
name=self._name,
|
||||
manufacturer=device_info["manufacturer"],
|
||||
model=device_info["model"],
|
||||
)
|
||||
|
||||
@callback
|
||||
def is_on_updated(is_on: bool) -> None:
|
||||
self._attr_is_on = is_on
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def current_app_updated(current_app: str) -> None:
|
||||
self._attr_current_activity = current_app
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def is_available_updated(is_available: bool) -> None:
|
||||
if is_available:
|
||||
_LOGGER.info(
|
||||
"Reconnected to %s at %s",
|
||||
self._name,
|
||||
self._host,
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Disconnected from %s at %s",
|
||||
self._name,
|
||||
self._host,
|
||||
)
|
||||
self._attr_available = is_available
|
||||
self.async_write_ha_state()
|
||||
|
||||
api.add_is_on_updated_callback(is_on_updated)
|
||||
api.add_current_app_updated_callback(current_app_updated)
|
||||
api.add_is_available_updated_callback(is_available_updated)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the Android TV on."""
|
||||
if not self.is_on:
|
||||
self._send_key_command("POWER")
|
||||
activity = kwargs.get(ATTR_ACTIVITY, "")
|
||||
if activity:
|
||||
self._send_launch_app_command(activity)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the Android TV off."""
|
||||
if self.is_on:
|
||||
self._send_key_command("POWER")
|
||||
|
||||
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
"""Send commands to one device."""
|
||||
num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS)
|
||||
delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
|
||||
hold_secs = kwargs.get(ATTR_HOLD_SECS, DEFAULT_HOLD_SECS)
|
||||
|
||||
for _ in range(num_repeats):
|
||||
for single_command in command:
|
||||
if hold_secs:
|
||||
self._send_key_command(single_command, "START_LONG")
|
||||
await asyncio.sleep(hold_secs)
|
||||
self._send_key_command(single_command, "END_LONG")
|
||||
else:
|
||||
self._send_key_command(single_command, "SHORT")
|
||||
await asyncio.sleep(delay_secs)
|
||||
|
||||
def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None:
|
||||
"""Send a key press to Android TV.
|
||||
|
||||
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
|
||||
"""
|
||||
try:
|
||||
self._api.send_key_command(key_code, direction)
|
||||
except ConnectionClosed as exc:
|
||||
raise HomeAssistantError(
|
||||
"Connection to Android TV device is closed"
|
||||
) from exc
|
||||
|
||||
def _send_launch_app_command(self, app_link: str) -> None:
|
||||
"""Launch an app on Android TV.
|
||||
|
||||
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
|
||||
"""
|
||||
try:
|
||||
self._api.send_launch_app_command(app_link)
|
||||
except ConnectionClosed as exc:
|
||||
raise HomeAssistantError(
|
||||
"Connection to Android TV device is closed"
|
||||
) from exc
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Enter the IP address of the Android TV you want to add to Home Assistant. It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
}
|
||||
},
|
||||
"zeroconf_confirm": {
|
||||
"title": "Discovered Android TV",
|
||||
"description": "Do you want to add the Android TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen."
|
||||
},
|
||||
"pair": {
|
||||
"description": "Enter the pairing code displayed on the Android TV ({name}).",
|
||||
"data": {
|
||||
"pin": "[%key:common::config_flow::data::pin%]"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "You need to pair again with the Android TV ({name})."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"no_status": "No status is reported from [%key:common::config_flow::data::host%]"
|
||||
"no_status": "No status is reported from host"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
|
||||
@@ -75,7 +75,7 @@ class AuthorizationServer:
|
||||
token_url: str
|
||||
|
||||
|
||||
class ApplicationCredentialsStorageCollection(collection.StorageCollection):
|
||||
class ApplicationCredentialsStorageCollection(collection.DictStorageCollection):
|
||||
"""Application credential collection stored in storage."""
|
||||
|
||||
CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
|
||||
@@ -94,7 +94,7 @@ class ApplicationCredentialsStorageCollection(collection.StorageCollection):
|
||||
return f"{info[CONF_DOMAIN]}.{info[CONF_CLIENT_ID]}"
|
||||
|
||||
async def _update_data(
|
||||
self, data: dict[str, str], update_data: dict[str, str]
|
||||
self, item: dict[str, str], update_data: dict[str, str]
|
||||
) -> dict[str, str]:
|
||||
"""Return a new updated data object."""
|
||||
raise ValueError("Updates not supported")
|
||||
@@ -144,13 +144,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
id_manager = collection.IDManager()
|
||||
storage_collection = ApplicationCredentialsStorageCollection(
|
||||
Store(hass, STORAGE_VERSION, STORAGE_KEY),
|
||||
logging.getLogger(f"{__name__}.storage_collection"),
|
||||
id_manager,
|
||||
)
|
||||
await storage_collection.async_load()
|
||||
hass.data[DOMAIN][DATA_STORAGE] = storage_collection
|
||||
|
||||
collection.StorageCollectionWebsocket(
|
||||
collection.DictStorageCollectionWebsocket(
|
||||
storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
|
||||
).async_setup(hass)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/arcam_fmj",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["arcam"],
|
||||
"requirements": ["arcam-fmj==1.2.1"],
|
||||
"requirements": ["arcam-fmj==1.3.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""The Assist pipeline integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
|
||||
from homeassistant.components import stt
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .error import PipelineNotFound
|
||||
from .pipeline import (
|
||||
Pipeline,
|
||||
PipelineEvent,
|
||||
PipelineEventCallback,
|
||||
PipelineEventType,
|
||||
PipelineInput,
|
||||
PipelineRun,
|
||||
PipelineStage,
|
||||
async_get_pipeline,
|
||||
async_setup_pipeline_store,
|
||||
)
|
||||
from .websocket_api import async_register_websocket_api
|
||||
|
||||
__all__ = (
|
||||
"DOMAIN",
|
||||
"async_setup",
|
||||
"async_pipeline_from_audio_stream",
|
||||
"Pipeline",
|
||||
"PipelineEvent",
|
||||
"PipelineEventType",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Assist pipeline integration."""
|
||||
await async_setup_pipeline_store(hass)
|
||||
async_register_websocket_api(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_pipeline_from_audio_stream(
|
||||
hass: HomeAssistant,
|
||||
context: Context,
|
||||
event_callback: PipelineEventCallback,
|
||||
stt_metadata: stt.SpeechMetadata,
|
||||
stt_stream: AsyncIterable[bytes],
|
||||
pipeline_id: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
tts_options: dict | None = None,
|
||||
) -> None:
|
||||
"""Create an audio pipeline from an audio stream."""
|
||||
pipeline = await async_get_pipeline(hass, pipeline_id=pipeline_id)
|
||||
if pipeline is None:
|
||||
raise PipelineNotFound(
|
||||
"pipeline_not_found", f"Pipeline {pipeline_id} not found"
|
||||
)
|
||||
|
||||
if stt_metadata.language == "":
|
||||
stt_metadata.language = pipeline.language
|
||||
|
||||
pipeline_input = PipelineInput(
|
||||
conversation_id=conversation_id,
|
||||
stt_metadata=stt_metadata,
|
||||
stt_stream=stt_stream,
|
||||
run=PipelineRun(
|
||||
hass,
|
||||
context=context,
|
||||
pipeline=pipeline,
|
||||
start_stage=PipelineStage.STT,
|
||||
end_stage=PipelineStage.TTS,
|
||||
event_callback=event_callback,
|
||||
tts_options=tts_options,
|
||||
),
|
||||
)
|
||||
|
||||
await pipeline_input.validate()
|
||||
await pipeline_input.execute()
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Constants for the Assist pipeline integration."""
|
||||
DOMAIN = "assist_pipeline"
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Assist pipeline errors."""
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class PipelineError(HomeAssistantError):
|
||||
"""Base class for pipeline errors."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
"""Set error message."""
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
super().__init__(f"Pipeline error code={code}, message={message}")
|
||||
|
||||
|
||||
class PipelineNotFound(PipelineError):
|
||||
"""Unspecified pipeline picked."""
|
||||
|
||||
|
||||
class SpeechToTextError(PipelineError):
|
||||
"""Error in speech to text portion of pipeline."""
|
||||
|
||||
|
||||
class IntentRecognitionError(PipelineError):
|
||||
"""Error in intent recognition portion of pipeline."""
|
||||
|
||||
|
||||
class TextToSpeechError(PipelineError):
|
||||
"""Error in text to speech portion of pipeline."""
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "assist_pipeline",
|
||||
"name": "Assist pipeline",
|
||||
"codeowners": ["@balloob", "@synesthesiam"],
|
||||
"dependencies": ["conversation", "stt", "tts"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_pipeline",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["webrtcvad==2.0.10"]
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
"""Classes for voice assistant pipelines."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable, Callable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.backports.enum import StrEnum
|
||||
from homeassistant.components import conversation, media_source, stt, tts, websocket_api
|
||||
from homeassistant.components.tts.media_source import (
|
||||
generate_media_source_id as tts_generate_media_source_id,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.helpers.collection import (
|
||||
CollectionError,
|
||||
ItemNotFound,
|
||||
SerializedStorageCollection,
|
||||
StorageCollection,
|
||||
StorageCollectionWebsocket,
|
||||
)
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util import dt as dt_util, ulid as ulid_util
|
||||
from homeassistant.util.limited_size_dict import LimitedSizeDict
|
||||
|
||||
from .const import DOMAIN
|
||||
from .error import (
|
||||
IntentRecognitionError,
|
||||
PipelineError,
|
||||
SpeechToTextError,
|
||||
TextToSpeechError,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STORAGE_KEY = f"{DOMAIN}.pipelines"
|
||||
STORAGE_VERSION = 1
|
||||
|
||||
ENGINE_LANGUAGE_PAIRS = (
|
||||
("stt_engine", "stt_language"),
|
||||
("tts_engine", "tts_language"),
|
||||
)
|
||||
|
||||
|
||||
def validate_language(data: dict[str, Any]) -> Any:
|
||||
"""Validate language settings."""
|
||||
for engine, language in ENGINE_LANGUAGE_PAIRS:
|
||||
if data[engine] is not None and data[language] is None:
|
||||
raise vol.Invalid(f"Need language {language} for {engine} {data[engine]}")
|
||||
return data
|
||||
|
||||
|
||||
PIPELINE_FIELDS = {
|
||||
vol.Required("conversation_engine"): str,
|
||||
vol.Required("conversation_language"): str,
|
||||
vol.Required("language"): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Required("stt_engine"): vol.Any(str, None),
|
||||
vol.Required("stt_language"): vol.Any(str, None),
|
||||
vol.Required("tts_engine"): vol.Any(str, None),
|
||||
vol.Required("tts_language"): vol.Any(str, None),
|
||||
vol.Required("tts_voice"): vol.Any(str, None),
|
||||
}
|
||||
|
||||
STORED_PIPELINE_RUNS = 10
|
||||
|
||||
SAVE_DELAY = 10
|
||||
|
||||
|
||||
async def async_get_pipeline(
|
||||
hass: HomeAssistant, pipeline_id: str | None = None
|
||||
) -> Pipeline | None:
|
||||
"""Get a pipeline by id or create one for a language."""
|
||||
pipeline_data: PipelineData = hass.data[DOMAIN]
|
||||
|
||||
if pipeline_id is None:
|
||||
# A pipeline was not specified, use the preferred one
|
||||
pipeline_id = pipeline_data.pipeline_store.async_get_preferred_item()
|
||||
|
||||
if pipeline_id is None:
|
||||
# There's no preferred pipeline, construct a pipeline for the
|
||||
# configured language
|
||||
return await pipeline_data.pipeline_store.async_create_item(
|
||||
{
|
||||
"conversation_engine": None,
|
||||
"conversation_language": None,
|
||||
"language": hass.config.language,
|
||||
"name": hass.config.language,
|
||||
"stt_engine": None,
|
||||
"stt_language": None,
|
||||
"tts_engine": None,
|
||||
"tts_language": None,
|
||||
"tts_voice": None,
|
||||
}
|
||||
)
|
||||
|
||||
return pipeline_data.pipeline_store.data.get(pipeline_id)
|
||||
|
||||
|
||||
class PipelineEventType(StrEnum):
|
||||
"""Event types emitted during a pipeline run."""
|
||||
|
||||
RUN_START = "run-start"
|
||||
RUN_END = "run-end"
|
||||
STT_START = "stt-start"
|
||||
STT_END = "stt-end"
|
||||
INTENT_START = "intent-start"
|
||||
INTENT_END = "intent-end"
|
||||
TTS_START = "tts-start"
|
||||
TTS_END = "tts-end"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PipelineEvent:
|
||||
"""Events emitted during a pipeline run."""
|
||||
|
||||
type: PipelineEventType
|
||||
data: dict[str, Any] | None = None
|
||||
timestamp: str = field(default_factory=lambda: dt_util.utcnow().isoformat())
|
||||
|
||||
|
||||
PipelineEventCallback = Callable[[PipelineEvent], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pipeline:
|
||||
"""A voice assistant pipeline."""
|
||||
|
||||
conversation_engine: str
|
||||
conversation_language: str
|
||||
language: str
|
||||
name: str
|
||||
stt_engine: str | None
|
||||
stt_language: str | None
|
||||
tts_engine: str | None
|
||||
tts_language: str | None
|
||||
tts_voice: str | None
|
||||
|
||||
id: str = field(default_factory=ulid_util.ulid)
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
"""Return a JSON serializable representation for storage."""
|
||||
return {
|
||||
"conversation_engine": self.conversation_engine,
|
||||
"conversation_language": self.conversation_language,
|
||||
"id": self.id,
|
||||
"language": self.language,
|
||||
"name": self.name,
|
||||
"stt_engine": self.stt_engine,
|
||||
"stt_language": self.stt_language,
|
||||
"tts_engine": self.tts_engine,
|
||||
"tts_language": self.tts_language,
|
||||
"tts_voice": self.tts_voice,
|
||||
}
|
||||
|
||||
|
||||
class PipelineStage(StrEnum):
|
||||
"""Stages of a pipeline."""
|
||||
|
||||
STT = "stt"
|
||||
INTENT = "intent"
|
||||
TTS = "tts"
|
||||
|
||||
|
||||
PIPELINE_STAGE_ORDER = [
|
||||
PipelineStage.STT,
|
||||
PipelineStage.INTENT,
|
||||
PipelineStage.TTS,
|
||||
]
|
||||
|
||||
|
||||
class PipelineRunValidationError(Exception):
|
||||
"""Error when a pipeline run is not valid."""
|
||||
|
||||
|
||||
class InvalidPipelineStagesError(PipelineRunValidationError):
|
||||
"""Error when given an invalid combination of start/end stages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
start_stage: PipelineStage,
|
||||
end_stage: PipelineStage,
|
||||
) -> None:
|
||||
"""Set error message."""
|
||||
super().__init__(
|
||||
f"Invalid stage combination: start={start_stage}, end={end_stage}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineRun:
|
||||
"""Running context for a pipeline."""
|
||||
|
||||
hass: HomeAssistant
|
||||
context: Context
|
||||
pipeline: Pipeline
|
||||
start_stage: PipelineStage
|
||||
end_stage: PipelineStage
|
||||
event_callback: PipelineEventCallback
|
||||
language: str = None # type: ignore[assignment]
|
||||
runner_data: Any | None = None
|
||||
stt_provider: stt.SpeechToTextEntity | stt.Provider | None = None
|
||||
intent_agent: str | None = None
|
||||
tts_engine: str | None = None
|
||||
tts_options: dict | None = None
|
||||
|
||||
id: str = field(default_factory=ulid_util.ulid)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Set language for pipeline."""
|
||||
self.language = self.pipeline.language or self.hass.config.language
|
||||
|
||||
# stt -> intent -> tts
|
||||
if PIPELINE_STAGE_ORDER.index(self.end_stage) < PIPELINE_STAGE_ORDER.index(
|
||||
self.start_stage
|
||||
):
|
||||
raise InvalidPipelineStagesError(self.start_stage, self.end_stage)
|
||||
|
||||
pipeline_data: PipelineData = self.hass.data[DOMAIN]
|
||||
if self.pipeline.id not in pipeline_data.pipeline_runs:
|
||||
pipeline_data.pipeline_runs[self.pipeline.id] = LimitedSizeDict(
|
||||
size_limit=STORED_PIPELINE_RUNS
|
||||
)
|
||||
pipeline_data.pipeline_runs[self.pipeline.id][self.id] = PipelineRunDebug()
|
||||
|
||||
@callback
|
||||
def process_event(self, event: PipelineEvent) -> None:
|
||||
"""Log an event and call listener."""
|
||||
self.event_callback(event)
|
||||
pipeline_data: PipelineData = self.hass.data[DOMAIN]
|
||||
if self.id not in pipeline_data.pipeline_runs[self.pipeline.id]:
|
||||
# This run has been evicted from the logged pipeline runs already
|
||||
return
|
||||
pipeline_data.pipeline_runs[self.pipeline.id][self.id].events.append(event)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Emit run start event."""
|
||||
data = {
|
||||
"pipeline": self.pipeline.name,
|
||||
"language": self.language,
|
||||
}
|
||||
if self.runner_data is not None:
|
||||
data["runner_data"] = self.runner_data
|
||||
|
||||
self.process_event(PipelineEvent(PipelineEventType.RUN_START, data))
|
||||
|
||||
def end(self) -> None:
|
||||
"""Emit run end event."""
|
||||
self.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.RUN_END,
|
||||
)
|
||||
)
|
||||
|
||||
async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None:
|
||||
"""Prepare speech to text."""
|
||||
stt_provider: stt.SpeechToTextEntity | stt.Provider | None = None
|
||||
|
||||
if self.pipeline.stt_engine is not None:
|
||||
# Try entity first
|
||||
stt_provider = stt.async_get_speech_to_text_entity(
|
||||
self.hass,
|
||||
self.pipeline.stt_engine,
|
||||
)
|
||||
|
||||
if stt_provider is None:
|
||||
# Try legacy provider second
|
||||
stt_provider = stt.async_get_provider(
|
||||
self.hass,
|
||||
self.pipeline.stt_engine,
|
||||
)
|
||||
|
||||
if stt_provider is None:
|
||||
engine = self.pipeline.stt_engine or "default"
|
||||
raise SpeechToTextError(
|
||||
code="stt-provider-missing",
|
||||
message=f"No speech to text provider for: {engine}",
|
||||
)
|
||||
|
||||
if not stt_provider.check_metadata(metadata):
|
||||
raise SpeechToTextError(
|
||||
code="stt-provider-unsupported-metadata",
|
||||
message=(
|
||||
f"Provider {stt_provider.name} does not support input speech "
|
||||
"to text metadata"
|
||||
),
|
||||
)
|
||||
|
||||
self.stt_provider = stt_provider
|
||||
|
||||
async def speech_to_text(
|
||||
self,
|
||||
metadata: stt.SpeechMetadata,
|
||||
stream: AsyncIterable[bytes],
|
||||
) -> str:
|
||||
"""Run speech to text portion of pipeline. Returns the spoken text."""
|
||||
if self.stt_provider is None:
|
||||
raise RuntimeError("Speech to text was not prepared")
|
||||
|
||||
engine = self.stt_provider.name
|
||||
|
||||
self.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.STT_START,
|
||||
{
|
||||
"engine": engine,
|
||||
"metadata": asdict(metadata),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Transcribe audio stream
|
||||
result = await self.stt_provider.async_process_audio_stream(
|
||||
metadata, stream
|
||||
)
|
||||
except Exception as src_error:
|
||||
_LOGGER.exception("Unexpected error during speech to text")
|
||||
raise SpeechToTextError(
|
||||
code="stt-stream-failed",
|
||||
message="Unexpected error during speech to text",
|
||||
) from src_error
|
||||
|
||||
_LOGGER.debug("speech-to-text result %s", result)
|
||||
|
||||
if result.result != stt.SpeechResultState.SUCCESS:
|
||||
raise SpeechToTextError(
|
||||
code="stt-stream-failed",
|
||||
message="Speech to text failed",
|
||||
)
|
||||
|
||||
if not result.text:
|
||||
raise SpeechToTextError(
|
||||
code="stt-no-text-recognized", message="No text recognized"
|
||||
)
|
||||
|
||||
self.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.STT_END,
|
||||
{
|
||||
"stt_output": {
|
||||
"text": result.text,
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return result.text
|
||||
|
||||
async def prepare_recognize_intent(self) -> None:
|
||||
"""Prepare recognizing an intent."""
|
||||
agent_info = conversation.async_get_agent_info(
|
||||
self.hass,
|
||||
# If no conversation engine is set, use the Home Assistant agent
|
||||
# (the conversation integration default is currently the last one set)
|
||||
self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT,
|
||||
)
|
||||
|
||||
if agent_info is None:
|
||||
engine = self.pipeline.conversation_engine or "default"
|
||||
raise IntentRecognitionError(
|
||||
code="intent-not-supported",
|
||||
message=f"Intent recognition engine {engine} is not found",
|
||||
)
|
||||
|
||||
self.intent_agent = agent_info.id
|
||||
|
||||
async def recognize_intent(
|
||||
self, intent_input: str, conversation_id: str | None
|
||||
) -> str:
|
||||
"""Run intent recognition portion of pipeline. Returns text to speak."""
|
||||
if self.intent_agent is None:
|
||||
raise RuntimeError("Recognize intent was not prepared")
|
||||
|
||||
self.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.INTENT_START,
|
||||
{
|
||||
"engine": self.intent_agent,
|
||||
"intent_input": intent_input,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
conversation_result = await conversation.async_converse(
|
||||
hass=self.hass,
|
||||
text=intent_input,
|
||||
conversation_id=conversation_id,
|
||||
context=self.context,
|
||||
language=self.language,
|
||||
agent_id=self.intent_agent,
|
||||
)
|
||||
except Exception as src_error:
|
||||
_LOGGER.exception("Unexpected error during intent recognition")
|
||||
raise IntentRecognitionError(
|
||||
code="intent-failed",
|
||||
message="Unexpected error during intent recognition",
|
||||
) from src_error
|
||||
|
||||
_LOGGER.debug("conversation result %s", conversation_result)
|
||||
|
||||
self.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.INTENT_END,
|
||||
{"intent_output": conversation_result.as_dict()},
|
||||
)
|
||||
)
|
||||
|
||||
speech: str = conversation_result.response.speech.get("plain", {}).get(
|
||||
"speech", ""
|
||||
)
|
||||
|
||||
return speech
|
||||
|
||||
async def prepare_text_to_speech(self) -> None:
|
||||
"""Prepare text to speech."""
|
||||
engine = tts.async_resolve_engine(self.hass, self.pipeline.tts_engine)
|
||||
|
||||
if engine is None:
|
||||
engine = self.pipeline.tts_engine or "default"
|
||||
raise TextToSpeechError(
|
||||
code="tts-not-supported",
|
||||
message=f"Text to speech engine '{engine}' not found",
|
||||
)
|
||||
|
||||
if not await tts.async_support_options(
|
||||
self.hass,
|
||||
engine,
|
||||
self.language,
|
||||
self.tts_options,
|
||||
):
|
||||
raise TextToSpeechError(
|
||||
code="tts-not-supported",
|
||||
message=(
|
||||
f"Text to speech engine {engine} "
|
||||
f"does not support language {self.language} or options {self.tts_options}"
|
||||
),
|
||||
)
|
||||
|
||||
self.tts_engine = engine
|
||||
|
||||
async def text_to_speech(self, tts_input: str) -> str:
|
||||
"""Run text to speech portion of pipeline. Returns URL of TTS audio."""
|
||||
if self.tts_engine is None:
|
||||
raise RuntimeError("Text to speech was not prepared")
|
||||
|
||||
self.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.TTS_START,
|
||||
{
|
||||
"engine": self.tts_engine,
|
||||
"tts_input": tts_input,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Synthesize audio and get URL
|
||||
tts_media_id = tts_generate_media_source_id(
|
||||
self.hass,
|
||||
tts_input,
|
||||
engine=self.tts_engine,
|
||||
language=self.language,
|
||||
options=self.tts_options,
|
||||
)
|
||||
tts_media = await media_source.async_resolve_media(
|
||||
self.hass,
|
||||
tts_media_id,
|
||||
None,
|
||||
)
|
||||
except Exception as src_error:
|
||||
_LOGGER.exception("Unexpected error during text to speech")
|
||||
raise TextToSpeechError(
|
||||
code="tts-failed",
|
||||
message="Unexpected error during text to speech",
|
||||
) from src_error
|
||||
|
||||
_LOGGER.debug("TTS result %s", tts_media)
|
||||
|
||||
self.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.TTS_END,
|
||||
{
|
||||
"tts_output": {
|
||||
"media_id": tts_media_id,
|
||||
**asdict(tts_media),
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return tts_media.url
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineInput:
|
||||
"""Input to a pipeline run."""
|
||||
|
||||
run: PipelineRun
|
||||
|
||||
stt_metadata: stt.SpeechMetadata | None = None
|
||||
"""Metadata of stt input audio. Required when start_stage = stt."""
|
||||
|
||||
stt_stream: AsyncIterable[bytes] | None = None
|
||||
"""Input audio for stt. Required when start_stage = stt."""
|
||||
|
||||
intent_input: str | None = None
|
||||
"""Input for conversation agent. Required when start_stage = intent."""
|
||||
|
||||
tts_input: str | None = None
|
||||
"""Input for text to speech. Required when start_stage = tts."""
|
||||
|
||||
conversation_id: str | None = None
|
||||
|
||||
async def execute(self) -> None:
|
||||
"""Run pipeline."""
|
||||
self.run.start()
|
||||
current_stage = self.run.start_stage
|
||||
|
||||
try:
|
||||
# Speech to text
|
||||
intent_input = self.intent_input
|
||||
if current_stage == PipelineStage.STT:
|
||||
assert self.stt_metadata is not None
|
||||
assert self.stt_stream is not None
|
||||
intent_input = await self.run.speech_to_text(
|
||||
self.stt_metadata,
|
||||
self.stt_stream,
|
||||
)
|
||||
current_stage = PipelineStage.INTENT
|
||||
|
||||
if self.run.end_stage != PipelineStage.STT:
|
||||
tts_input = self.tts_input
|
||||
|
||||
if current_stage == PipelineStage.INTENT:
|
||||
assert intent_input is not None
|
||||
tts_input = await self.run.recognize_intent(
|
||||
intent_input, self.conversation_id
|
||||
)
|
||||
current_stage = PipelineStage.TTS
|
||||
|
||||
if self.run.end_stage != PipelineStage.INTENT:
|
||||
if current_stage == PipelineStage.TTS:
|
||||
assert tts_input is not None
|
||||
await self.run.text_to_speech(tts_input)
|
||||
|
||||
except PipelineError as err:
|
||||
self.run.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.ERROR,
|
||||
{"code": err.code, "message": err.message},
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
self.run.end()
|
||||
|
||||
async def validate(self) -> None:
|
||||
"""Validate pipeline input against start stage."""
|
||||
if self.run.start_stage == PipelineStage.STT:
|
||||
if self.stt_metadata is None:
|
||||
raise PipelineRunValidationError(
|
||||
"stt_metadata is required for speech to text"
|
||||
)
|
||||
|
||||
if self.stt_stream is None:
|
||||
raise PipelineRunValidationError(
|
||||
"stt_stream is required for speech to text"
|
||||
)
|
||||
elif self.run.start_stage == PipelineStage.INTENT:
|
||||
if self.intent_input is None:
|
||||
raise PipelineRunValidationError(
|
||||
"intent_input is required for intent recognition"
|
||||
)
|
||||
elif self.run.start_stage == PipelineStage.TTS:
|
||||
if self.tts_input is None:
|
||||
raise PipelineRunValidationError(
|
||||
"tts_input is required for text to speech"
|
||||
)
|
||||
|
||||
start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage)
|
||||
|
||||
prepare_tasks = []
|
||||
|
||||
if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT):
|
||||
# self.stt_metadata can't be None or we'd raise above
|
||||
prepare_tasks.append(self.run.prepare_speech_to_text(self.stt_metadata)) # type: ignore[arg-type]
|
||||
|
||||
if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT):
|
||||
prepare_tasks.append(self.run.prepare_recognize_intent())
|
||||
|
||||
if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS):
|
||||
prepare_tasks.append(self.run.prepare_text_to_speech())
|
||||
|
||||
if prepare_tasks:
|
||||
await asyncio.gather(*prepare_tasks)
|
||||
|
||||
|
||||
class PipelinePreferred(CollectionError):
|
||||
"""Raised when attempting to delete the preferred pipelen."""
|
||||
|
||||
def __init__(self, item_id: str) -> None:
|
||||
"""Initialize pipeline preferred error."""
|
||||
super().__init__(f"Item {item_id} preferred.")
|
||||
self.item_id = item_id
|
||||
|
||||
|
||||
class SerializedPipelineStorageCollection(SerializedStorageCollection):
|
||||
"""Serialized pipeline storage collection."""
|
||||
|
||||
preferred_item: str | None
|
||||
|
||||
|
||||
class PipelineStorageCollection(
|
||||
StorageCollection[Pipeline, SerializedPipelineStorageCollection]
|
||||
):
|
||||
"""Pipeline storage collection."""
|
||||
|
||||
_preferred_item: str | None = None
|
||||
|
||||
async def _async_load_data(self) -> SerializedPipelineStorageCollection | None:
|
||||
"""Load the data."""
|
||||
if not (data := await super()._async_load_data()):
|
||||
return data
|
||||
|
||||
self._preferred_item = data["preferred_item"]
|
||||
|
||||
return data
|
||||
|
||||
async def _process_create_data(self, data: dict) -> dict:
|
||||
"""Validate the config is valid."""
|
||||
validated_data: dict = validate_language(data)
|
||||
return validated_data
|
||||
|
||||
@callback
|
||||
def _get_suggested_id(self, info: dict) -> str:
|
||||
"""Suggest an ID based on the config."""
|
||||
return ulid_util.ulid()
|
||||
|
||||
async def _update_data(self, item: Pipeline, update_data: dict) -> Pipeline:
|
||||
"""Return a new updated item."""
|
||||
update_data = validate_language(update_data)
|
||||
return Pipeline(id=item.id, **update_data)
|
||||
|
||||
def _create_item(self, item_id: str, data: dict) -> Pipeline:
|
||||
"""Create an item from validated config."""
|
||||
if self._preferred_item is None:
|
||||
self._preferred_item = item_id
|
||||
return Pipeline(id=item_id, **data)
|
||||
|
||||
def _deserialize_item(self, data: dict) -> Pipeline:
|
||||
"""Create an item from its serialized representation."""
|
||||
return Pipeline(**data)
|
||||
|
||||
def _serialize_item(self, item_id: str, item: Pipeline) -> dict:
|
||||
"""Return the serialized representation of an item for storing."""
|
||||
return item.to_json()
|
||||
|
||||
async def async_delete_item(self, item_id: str) -> None:
|
||||
"""Delete item."""
|
||||
if self._preferred_item == item_id:
|
||||
raise PipelinePreferred(item_id)
|
||||
await super().async_delete_item(item_id)
|
||||
|
||||
@callback
|
||||
def async_get_preferred_item(self) -> str | None:
|
||||
"""Get the id of the preferred item."""
|
||||
return self._preferred_item
|
||||
|
||||
@callback
|
||||
def async_set_preferred_item(self, item_id: str) -> None:
|
||||
"""Set the preferred pipeline."""
|
||||
if item_id not in self.data:
|
||||
raise ItemNotFound(item_id)
|
||||
self._preferred_item = item_id
|
||||
self._async_schedule_save()
|
||||
|
||||
@callback
|
||||
def _data_to_save(self) -> SerializedPipelineStorageCollection:
|
||||
"""Return JSON-compatible date for storing to file."""
|
||||
base_data = super()._base_data_to_save()
|
||||
return {
|
||||
"items": base_data["items"],
|
||||
"preferred_item": self._preferred_item,
|
||||
}
|
||||
|
||||
|
||||
class PipelineStorageCollectionWebsocket(
|
||||
StorageCollectionWebsocket[PipelineStorageCollection]
|
||||
):
|
||||
"""Class to expose storage collection management over websocket."""
|
||||
|
||||
@callback
|
||||
def async_setup(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
create_list: bool = True,
|
||||
create_create: bool = True,
|
||||
) -> None:
|
||||
"""Set up the websocket commands."""
|
||||
super().async_setup(hass, create_list=create_list, create_create=create_create)
|
||||
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
f"{self.api_prefix}/get",
|
||||
self.ws_get_item,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required("type"): f"{self.api_prefix}/get",
|
||||
vol.Optional(self.item_id_key): str,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
f"{self.api_prefix}/set_preferred",
|
||||
websocket_api.require_admin(
|
||||
websocket_api.async_response(self.ws_set_preferred_item)
|
||||
),
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required("type"): f"{self.api_prefix}/set_preferred",
|
||||
vol.Required(self.item_id_key): str,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def ws_delete_item(
|
||||
self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""Delete an item."""
|
||||
try:
|
||||
await super().ws_delete_item(hass, connection, msg)
|
||||
except PipelinePreferred as exc:
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.const.ERR_NOT_ALLOWED, str(exc)
|
||||
)
|
||||
|
||||
@callback
|
||||
def ws_get_item(
|
||||
self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""Get an item."""
|
||||
item_id = msg.get(self.item_id_key)
|
||||
if item_id is None:
|
||||
item_id = self.storage_collection.async_get_preferred_item()
|
||||
|
||||
if item_id not in self.storage_collection.data:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.const.ERR_NOT_FOUND,
|
||||
f"Unable to find {self.item_id_key} {item_id}",
|
||||
)
|
||||
return
|
||||
|
||||
connection.send_result(msg["id"], self.storage_collection.data[item_id])
|
||||
|
||||
@callback
|
||||
def ws_list_item(
|
||||
self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""List items."""
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"pipelines": self.storage_collection.async_items(),
|
||||
"preferred_pipeline": self.storage_collection.async_get_preferred_item(),
|
||||
},
|
||||
)
|
||||
|
||||
async def ws_set_preferred_item(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set the preferred item."""
|
||||
try:
|
||||
self.storage_collection.async_set_preferred_item(msg[self.item_id_key])
|
||||
except ItemNotFound:
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown item"
|
||||
)
|
||||
return
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineData:
|
||||
"""Store and debug data stored in hass.data."""
|
||||
|
||||
pipeline_runs: dict[str, LimitedSizeDict[str, PipelineRunDebug]]
|
||||
pipeline_store: PipelineStorageCollection
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineRunDebug:
|
||||
"""Debug data for a pipelinerun."""
|
||||
|
||||
events: list[PipelineEvent] = field(default_factory=list, init=False)
|
||||
timestamp: str = field(
|
||||
default_factory=lambda: dt_util.utcnow().isoformat(),
|
||||
init=False,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_pipeline_store(hass: HomeAssistant) -> None:
|
||||
"""Set up the pipeline storage collection."""
|
||||
pipeline_store = PipelineStorageCollection(
|
||||
Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
)
|
||||
await pipeline_store.async_load()
|
||||
PipelineStorageCollectionWebsocket(
|
||||
pipeline_store,
|
||||
f"{DOMAIN}/pipeline",
|
||||
"pipeline",
|
||||
PIPELINE_FIELDS,
|
||||
PIPELINE_FIELDS,
|
||||
).async_setup(hass)
|
||||
hass.data[DOMAIN] = PipelineData({}, pipeline_store)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Select entities for a pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import EntityCategory, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import collection, entity_registry as er, restore_state
|
||||
|
||||
from .const import DOMAIN
|
||||
from .pipeline import PipelineStorageCollection
|
||||
|
||||
OPTION_PREFERRED = "preferred"
|
||||
|
||||
|
||||
@callback
|
||||
def get_chosen_pipeline(
|
||||
hass: HomeAssistant, domain: str, unique_id_prefix: str
|
||||
) -> str | None:
|
||||
"""Get the chosen pipeline for a domain."""
|
||||
ent_reg = er.async_get(hass)
|
||||
pipeline_entity_id = ent_reg.async_get_entity_id(
|
||||
Platform.SELECT, domain, f"{unique_id_prefix}-pipeline"
|
||||
)
|
||||
if pipeline_entity_id is None:
|
||||
return None
|
||||
|
||||
state = hass.states.get(pipeline_entity_id)
|
||||
if state is None or state.state == OPTION_PREFERRED:
|
||||
return None
|
||||
|
||||
pipeline_store: PipelineStorageCollection = hass.data[DOMAIN].pipeline_store
|
||||
return next(
|
||||
(item.id for item in pipeline_store.async_items() if item.name == state.state),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
|
||||
"""Entity to represent a pipeline selector."""
|
||||
|
||||
entity_description = SelectEntityDescription(
|
||||
key="pipeline",
|
||||
translation_key="pipeline",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
)
|
||||
_attr_should_poll = False
|
||||
_attr_current_option = OPTION_PREFERRED
|
||||
_attr_options = [OPTION_PREFERRED]
|
||||
|
||||
def __init__(self, hass: HomeAssistant, unique_id_prefix: str) -> None:
|
||||
"""Initialize a pipeline selector."""
|
||||
self._attr_unique_id = f"{unique_id_prefix}-pipeline"
|
||||
self.hass = hass
|
||||
self._update_options()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to Home Assistant."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
pipeline_store: PipelineStorageCollection = self.hass.data[
|
||||
DOMAIN
|
||||
].pipeline_store
|
||||
pipeline_store.async_add_change_set_listener(self._pipelines_updated)
|
||||
|
||||
state = await self.async_get_last_state()
|
||||
if state is not None and state.state in self.options:
|
||||
self._attr_current_option = state.state
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Select an option."""
|
||||
self._attr_current_option = option
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _pipelines_updated(
|
||||
self, change_sets: Iterable[collection.CollectionChangeSet]
|
||||
) -> None:
|
||||
"""Handle pipeline update."""
|
||||
self._update_options()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _update_options(self) -> None:
|
||||
"""Handle pipeline update."""
|
||||
pipeline_store: PipelineStorageCollection = self.hass.data[
|
||||
DOMAIN
|
||||
].pipeline_store
|
||||
options = [OPTION_PREFERRED]
|
||||
options.extend(sorted(item.name for item in pipeline_store.async_items()))
|
||||
self._attr_options = options
|
||||
|
||||
if self._attr_current_option not in options:
|
||||
self._attr_current_option = OPTION_PREFERRED
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"pipeline": {
|
||||
"name": "Assist Pipeline",
|
||||
"state": {
|
||||
"preferred": "Preferred"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Voice activity detection."""
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import webrtcvad
|
||||
|
||||
_SAMPLE_RATE = 16000
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoiceCommandSegmenter:
|
||||
"""Segments an audio stream into voice commands using webrtcvad."""
|
||||
|
||||
vad_mode: int = 3
|
||||
"""Aggressiveness in filtering out non-speech. 3 is the most aggressive."""
|
||||
|
||||
vad_frames: int = 480 # 30 ms
|
||||
"""Must be 10, 20, or 30 ms at 16Khz."""
|
||||
|
||||
speech_seconds: float = 0.3
|
||||
"""Seconds of speech before voice command has started."""
|
||||
|
||||
silence_seconds: float = 0.5
|
||||
"""Seconds of silence after voice command has ended."""
|
||||
|
||||
timeout_seconds: float = 15.0
|
||||
"""Maximum number of seconds before stopping with timeout=True."""
|
||||
|
||||
reset_seconds: float = 1.0
|
||||
"""Seconds before reset start/stop time counters."""
|
||||
|
||||
in_command: bool = False
|
||||
"""True if inside voice command."""
|
||||
|
||||
_speech_seconds_left: float = 0.0
|
||||
"""Seconds left before considering voice command as started."""
|
||||
|
||||
_silence_seconds_left: float = 0.0
|
||||
"""Seconds left before considering voice command as stopped."""
|
||||
|
||||
_timeout_seconds_left: float = 0.0
|
||||
"""Seconds left before considering voice command timed out."""
|
||||
|
||||
_reset_seconds_left: float = 0.0
|
||||
"""Seconds left before resetting start/stop time counters."""
|
||||
|
||||
_vad: webrtcvad.Vad = None
|
||||
_audio_buffer: bytes = field(default_factory=bytes)
|
||||
_bytes_per_chunk: int = 480 * 2 # 16-bit samples
|
||||
_seconds_per_chunk: float = 0.03 # 30 ms
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Initialize VAD."""
|
||||
self._vad = webrtcvad.Vad(self.vad_mode)
|
||||
self._bytes_per_chunk = self.vad_frames * 2
|
||||
self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all counters and state."""
|
||||
self._audio_buffer = b""
|
||||
self._speech_seconds_left = self.speech_seconds
|
||||
self._silence_seconds_left = self.silence_seconds
|
||||
self._timeout_seconds_left = self.timeout_seconds
|
||||
self._reset_seconds_left = self.reset_seconds
|
||||
self.in_command = False
|
||||
|
||||
def process(self, samples: bytes) -> bool:
|
||||
"""Process a 16-bit 16Khz mono audio samples.
|
||||
|
||||
Returns False when command is done.
|
||||
"""
|
||||
self._audio_buffer += samples
|
||||
|
||||
# Process in 10, 20, or 30 ms chunks.
|
||||
num_chunks = len(self._audio_buffer) // self._bytes_per_chunk
|
||||
for chunk_idx in range(num_chunks):
|
||||
chunk_offset = chunk_idx * self._bytes_per_chunk
|
||||
chunk = self._audio_buffer[
|
||||
chunk_offset : chunk_offset + self._bytes_per_chunk
|
||||
]
|
||||
if not self._process_chunk(chunk):
|
||||
self.reset()
|
||||
return False
|
||||
|
||||
if num_chunks > 0:
|
||||
# Remove from buffer
|
||||
self._audio_buffer = self._audio_buffer[
|
||||
num_chunks * self._bytes_per_chunk :
|
||||
]
|
||||
|
||||
return True
|
||||
|
||||
def _process_chunk(self, chunk: bytes) -> bool:
|
||||
"""Process a single chunk of 16-bit 16Khz mono audio.
|
||||
|
||||
Returns False when command is done.
|
||||
"""
|
||||
is_speech = self._vad.is_speech(chunk, _SAMPLE_RATE)
|
||||
|
||||
self._timeout_seconds_left -= self._seconds_per_chunk
|
||||
if self._timeout_seconds_left <= 0:
|
||||
return False
|
||||
|
||||
if not self.in_command:
|
||||
if is_speech:
|
||||
self._reset_seconds_left = self.reset_seconds
|
||||
self._speech_seconds_left -= self._seconds_per_chunk
|
||||
if self._speech_seconds_left <= 0:
|
||||
# Inside voice command
|
||||
self.in_command = True
|
||||
else:
|
||||
# Reset if enough silence
|
||||
self._reset_seconds_left -= self._seconds_per_chunk
|
||||
if self._reset_seconds_left <= 0:
|
||||
self._speech_seconds_left = self.speech_seconds
|
||||
else:
|
||||
if not is_speech:
|
||||
self._reset_seconds_left = self.reset_seconds
|
||||
self._silence_seconds_left -= self._seconds_per_chunk
|
||||
if self._silence_seconds_left <= 0:
|
||||
return False
|
||||
else:
|
||||
# Reset if enough speech
|
||||
self._reset_seconds_left -= self._seconds_per_chunk
|
||||
if self._reset_seconds_left <= 0:
|
||||
self._silence_seconds_left = self.silence_seconds
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Assist pipeline Websocket API."""
|
||||
import asyncio
|
||||
import audioop # pylint: disable=deprecated-module
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import conversation, stt, tts, websocket_api
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import language as language_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .pipeline import (
|
||||
PipelineData,
|
||||
PipelineError,
|
||||
PipelineEvent,
|
||||
PipelineEventType,
|
||||
PipelineInput,
|
||||
PipelineRun,
|
||||
PipelineStage,
|
||||
async_get_pipeline,
|
||||
)
|
||||
from .vad import VoiceCommandSegmenter
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_websocket_api(hass: HomeAssistant) -> None:
|
||||
"""Register the websocket API."""
|
||||
websocket_api.async_register_command(hass, websocket_run)
|
||||
websocket_api.async_register_command(hass, websocket_list_languages)
|
||||
websocket_api.async_register_command(hass, websocket_list_runs)
|
||||
websocket_api.async_register_command(hass, websocket_get_run)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
vol.All(
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required("type"): "assist_pipeline/run",
|
||||
# pylint: disable-next=unnecessary-lambda
|
||||
vol.Required("start_stage"): lambda val: PipelineStage(val),
|
||||
# pylint: disable-next=unnecessary-lambda
|
||||
vol.Required("end_stage"): lambda val: PipelineStage(val),
|
||||
vol.Optional("input"): dict,
|
||||
vol.Optional("pipeline"): str,
|
||||
vol.Optional("conversation_id"): vol.Any(str, None),
|
||||
vol.Optional("timeout"): vol.Any(float, int),
|
||||
},
|
||||
),
|
||||
cv.key_value_schemas(
|
||||
"start_stage",
|
||||
{
|
||||
PipelineStage.STT: vol.Schema(
|
||||
{vol.Required("input"): {vol.Required("sample_rate"): int}},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
PipelineStage.INTENT: vol.Schema(
|
||||
{vol.Required("input"): {"text": str}},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
PipelineStage.TTS: vol.Schema(
|
||||
{vol.Required("input"): {"text": str}},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_run(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Run a pipeline."""
|
||||
pipeline_id = msg.get("pipeline")
|
||||
pipeline = await async_get_pipeline(hass, pipeline_id=pipeline_id)
|
||||
if pipeline is None:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"pipeline-not-found",
|
||||
f"Pipeline not found: id={pipeline_id}",
|
||||
)
|
||||
return
|
||||
|
||||
timeout = msg.get("timeout", DEFAULT_TIMEOUT)
|
||||
start_stage = PipelineStage(msg["start_stage"])
|
||||
end_stage = PipelineStage(msg["end_stage"])
|
||||
handler_id: int | None = None
|
||||
unregister_handler: Callable[[], None] | None = None
|
||||
|
||||
# Arguments to PipelineInput
|
||||
input_args: dict[str, Any] = {
|
||||
"conversation_id": msg.get("conversation_id"),
|
||||
}
|
||||
|
||||
if start_stage == PipelineStage.STT:
|
||||
# Audio pipeline that will receive audio as binary websocket messages
|
||||
audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue()
|
||||
incoming_sample_rate = msg["input"]["sample_rate"]
|
||||
|
||||
async def stt_stream() -> AsyncGenerator[bytes, None]:
|
||||
state = None
|
||||
segmenter = VoiceCommandSegmenter()
|
||||
|
||||
# Yield until we receive an empty chunk
|
||||
while chunk := await audio_queue.get():
|
||||
chunk, state = audioop.ratecv(
|
||||
chunk, 2, 1, incoming_sample_rate, 16000, state
|
||||
)
|
||||
if not segmenter.process(chunk):
|
||||
# Voice command is finished
|
||||
break
|
||||
|
||||
yield chunk
|
||||
|
||||
def handle_binary(
|
||||
_hass: HomeAssistant,
|
||||
_connection: websocket_api.ActiveConnection,
|
||||
data: bytes,
|
||||
) -> None:
|
||||
# Forward to STT audio stream
|
||||
audio_queue.put_nowait(data)
|
||||
|
||||
handler_id, unregister_handler = connection.async_register_binary_handler(
|
||||
handle_binary
|
||||
)
|
||||
|
||||
# Audio input must be raw PCM at 16Khz with 16-bit mono samples
|
||||
input_args["stt_metadata"] = stt.SpeechMetadata(
|
||||
language=pipeline.language,
|
||||
format=stt.AudioFormats.WAV,
|
||||
codec=stt.AudioCodecs.PCM,
|
||||
bit_rate=stt.AudioBitRates.BITRATE_16,
|
||||
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
|
||||
channel=stt.AudioChannels.CHANNEL_MONO,
|
||||
)
|
||||
input_args["stt_stream"] = stt_stream()
|
||||
elif start_stage == PipelineStage.INTENT:
|
||||
# Input to conversation agent
|
||||
input_args["intent_input"] = msg["input"]["text"]
|
||||
elif start_stage == PipelineStage.TTS:
|
||||
# Input to text to speech system
|
||||
input_args["tts_input"] = msg["input"]["text"]
|
||||
|
||||
input_args["run"] = PipelineRun(
|
||||
hass,
|
||||
context=connection.context(msg),
|
||||
pipeline=pipeline,
|
||||
start_stage=start_stage,
|
||||
end_stage=end_stage,
|
||||
event_callback=lambda event: connection.send_event(msg["id"], event),
|
||||
runner_data={
|
||||
"stt_binary_handler_id": handler_id,
|
||||
"timeout": timeout,
|
||||
},
|
||||
)
|
||||
|
||||
pipeline_input = PipelineInput(**input_args)
|
||||
|
||||
try:
|
||||
await pipeline_input.validate()
|
||||
except PipelineError as error:
|
||||
# Report more specific error when possible
|
||||
connection.send_error(msg["id"], error.code, error.message)
|
||||
return
|
||||
|
||||
# Confirm subscription
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
run_task = hass.async_create_task(pipeline_input.execute())
|
||||
|
||||
# Cancel pipeline if user unsubscribes
|
||||
connection.subscriptions[msg["id"]] = run_task.cancel
|
||||
|
||||
try:
|
||||
# Task contains a timeout
|
||||
async with async_timeout.timeout(timeout):
|
||||
await run_task
|
||||
except asyncio.TimeoutError:
|
||||
pipeline_input.run.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.ERROR,
|
||||
{"code": "timeout", "message": "Timeout running pipeline"},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
if unregister_handler is not None:
|
||||
# Unregister binary handler
|
||||
unregister_handler()
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "assist_pipeline/pipeline_debug/list",
|
||||
vol.Required("pipeline_id"): str,
|
||||
}
|
||||
)
|
||||
def websocket_list_runs(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""List pipeline runs for which debug data is available."""
|
||||
pipeline_data: PipelineData = hass.data[DOMAIN]
|
||||
pipeline_id = msg["pipeline_id"]
|
||||
|
||||
if pipeline_id not in pipeline_data.pipeline_runs:
|
||||
connection.send_result(msg["id"], {"pipeline_runs": []})
|
||||
return
|
||||
|
||||
pipeline_runs = pipeline_data.pipeline_runs[pipeline_id]
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"pipeline_runs": [
|
||||
{"pipeline_run_id": id, "timestamp": pipeline_run.timestamp}
|
||||
for id, pipeline_run in pipeline_runs.items()
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "assist_pipeline/pipeline_debug/get",
|
||||
vol.Required("pipeline_id"): str,
|
||||
vol.Required("pipeline_run_id"): str,
|
||||
}
|
||||
)
|
||||
def websocket_get_run(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get debug data for a pipeline run."""
|
||||
pipeline_data: PipelineData = hass.data[DOMAIN]
|
||||
pipeline_id = msg["pipeline_id"]
|
||||
pipeline_run_id = msg["pipeline_run_id"]
|
||||
|
||||
if pipeline_id not in pipeline_data.pipeline_runs:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.const.ERR_NOT_FOUND,
|
||||
f"pipeline_id {pipeline_id} not found",
|
||||
)
|
||||
return
|
||||
|
||||
pipeline_runs = pipeline_data.pipeline_runs[pipeline_id]
|
||||
|
||||
if pipeline_run_id not in pipeline_runs:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.const.ERR_NOT_FOUND,
|
||||
f"pipeline_run_id {pipeline_run_id} not found",
|
||||
)
|
||||
return
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{"events": pipeline_runs[pipeline_run_id].events},
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "assist_pipeline/language/list",
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_list_languages(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""List languages which are supported by a complete pipeline.
|
||||
|
||||
This will return a list of languages which are supported by at least one stt, tts
|
||||
and conversation engine respectively.
|
||||
"""
|
||||
conv_language_tags = await conversation.async_get_conversation_languages(hass)
|
||||
stt_language_tags = stt.async_get_speech_to_text_languages(hass)
|
||||
tts_language_tags = tts.async_get_text_to_speech_languages(hass)
|
||||
pipeline_languages: set[str] | None = None
|
||||
|
||||
if conv_language_tags and conv_language_tags != MATCH_ALL:
|
||||
languages = set()
|
||||
for language_tag in conv_language_tags:
|
||||
dialect = language_util.Dialect.parse(language_tag)
|
||||
languages.add(dialect.language)
|
||||
pipeline_languages = languages
|
||||
|
||||
if stt_language_tags:
|
||||
languages = set()
|
||||
for language_tag in stt_language_tags:
|
||||
dialect = language_util.Dialect.parse(language_tag)
|
||||
languages.add(dialect.language)
|
||||
if pipeline_languages is not None:
|
||||
pipeline_languages &= languages
|
||||
else:
|
||||
pipeline_languages = languages
|
||||
|
||||
if tts_language_tags:
|
||||
languages = set()
|
||||
for language_tag in tts_language_tags:
|
||||
dialect = language_util.Dialect.parse(language_tag)
|
||||
languages.add(dialect.language)
|
||||
if pipeline_languages is not None:
|
||||
pipeline_languages &= languages
|
||||
else:
|
||||
pipeline_languages = languages
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{"languages": pipeline_languages},
|
||||
)
|
||||
@@ -11,7 +11,7 @@
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"ssh_key": "Path to your SSH key file (instead of password)",
|
||||
"protocol": "Communication protocol to use",
|
||||
"port": "[%key:common::config_flow::data::port%] (leave empty for protocol default)",
|
||||
"port": "Port (leave empty for protocol default)",
|
||||
"mode": "[%key:common::config_flow::data::mode%]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.14"]
|
||||
"requirements": ["yalexs==1.3.0", "yalexs-ble==2.1.14"]
|
||||
}
|
||||
|
||||
@@ -38,7 +38,10 @@ class AugustSubscriberMixin:
|
||||
def _async_setup_listeners(self):
|
||||
"""Create interval and stop listeners."""
|
||||
self._unsub_interval = async_track_time_interval(
|
||||
self._hass, self._async_refresh, self._update_interval, "august refresh"
|
||||
self._hass,
|
||||
self._async_refresh,
|
||||
self._update_interval,
|
||||
name="august refresh",
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -659,7 +659,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class AutomationEntityConfig:
|
||||
"""Container for prepared automation entity configuration."""
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if config_entry.version != 3:
|
||||
# Home Assistant 2023.2
|
||||
config_entry.version = 3
|
||||
hass.config_entries.async_update_entry(config_entry)
|
||||
|
||||
_LOGGER.info("Migration to version %s successful", config_entry.version)
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ from homeassistant.util.json import json_loads_object
|
||||
|
||||
from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
|
||||
|
||||
BUF_SIZE = 2**20 * 4 # 4MB
|
||||
|
||||
@dataclass
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Backup:
|
||||
"""Backup class."""
|
||||
|
||||
@@ -99,7 +101,7 @@ class BackupManager:
|
||||
backups: dict[str, Backup] = {}
|
||||
for backup_path in self.backup_dir.glob("*.tar"):
|
||||
try:
|
||||
with tarfile.open(backup_path, "r:") as backup_file:
|
||||
with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file:
|
||||
if data_file := backup_file.extractfile("./backup.json"):
|
||||
data = json_loads_object(data_file.read())
|
||||
backup = Backup(
|
||||
@@ -227,7 +229,7 @@ class BackupManager:
|
||||
self.backup_dir.mkdir()
|
||||
|
||||
with TemporaryDirectory() as tmp_dir, SecureTarFile(
|
||||
tar_file_path, "w", gzip=False
|
||||
tar_file_path, "w", gzip=False, bufsize=BUF_SIZE
|
||||
) as tar_file:
|
||||
tmp_dir_path = Path(tmp_dir)
|
||||
save_json(
|
||||
@@ -237,6 +239,7 @@ class BackupManager:
|
||||
with SecureTarFile(
|
||||
tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(),
|
||||
"w",
|
||||
bufsize=BUF_SIZE,
|
||||
) as core_tar:
|
||||
atomic_contents_add(
|
||||
tar_file=core_tar,
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "calculated",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["securetar==2022.2.0"]
|
||||
"requirements": ["securetar==2023.3.0"]
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
) -> FlowResult:
|
||||
"""Handle zeroconf discovery."""
|
||||
hass = self.hass
|
||||
ipaddress = host_port(discovery_info.__dict__)
|
||||
ipaddress = (discovery_info.host, discovery_info.port)
|
||||
self.device_config["host"] = discovery_info.host
|
||||
self.device_config["port"] = discovery_info.port
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
)
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant, callback as hass_callback
|
||||
from homeassistant.core import Event, HassJob, HomeAssistant, callback as hass_callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, discovery_flow
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
@@ -198,10 +198,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
function=_async_rediscover_adapters,
|
||||
)
|
||||
|
||||
async def _async_shutdown_debouncer(_: Event) -> None:
|
||||
"""Shutdown debouncer."""
|
||||
await discovery_debouncer.async_shutdown()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_debouncer)
|
||||
|
||||
async def _async_call_debouncer(now: datetime.datetime) -> None:
|
||||
"""Call the debouncer at a later time."""
|
||||
await discovery_debouncer.async_call()
|
||||
|
||||
call_debouncer_job = HassJob(_async_call_debouncer, cancel_on_shutdown=True)
|
||||
|
||||
def _async_trigger_discovery() -> None:
|
||||
# There are so many bluetooth adapter models that
|
||||
# we check the bus whenever a usb device is plugged in
|
||||
@@ -220,7 +228,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async_call_later(
|
||||
hass,
|
||||
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS + LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS,
|
||||
_async_call_debouncer,
|
||||
call_debouncer_job,
|
||||
)
|
||||
|
||||
cancel = usb.async_register_scan_request_callback(hass, _async_trigger_discovery)
|
||||
|
||||
@@ -169,3 +169,9 @@ class ActiveBluetoothDataUpdateCoordinator(
|
||||
# possible after a device comes online or back in range, if a poll is due
|
||||
if self.needs_poll(service_info):
|
||||
self.hass.async_create_task(self._debounced_poll.async_call())
|
||||
|
||||
@callback
|
||||
def _async_stop(self) -> None:
|
||||
"""Cancel debouncer and stop the callbacks."""
|
||||
self._debounced_poll.async_cancel()
|
||||
super()._async_stop()
|
||||
|
||||
@@ -158,3 +158,9 @@ class ActiveBluetoothProcessorCoordinator(
|
||||
# possible after a device comes online or back in range, if a poll is due
|
||||
if self.needs_poll(service_info):
|
||||
self.hass.async_create_task(self._debounced_poll.async_call())
|
||||
|
||||
@callback
|
||||
def _async_stop(self) -> None:
|
||||
"""Cancel debouncer and stop the callbacks."""
|
||||
self._debounced_poll.async_cancel()
|
||||
super()._async_stop()
|
||||
|
||||
@@ -39,7 +39,7 @@ MONOTONIC_TIME: Final = monotonic_time_coarse
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class BluetoothScannerDevice:
|
||||
"""Data for a bluetooth device from a given scanner."""
|
||||
|
||||
@@ -101,7 +101,7 @@ class BaseHaScanner(ABC):
|
||||
self.hass,
|
||||
self._async_scanner_watchdog,
|
||||
SCANNER_WATCHDOG_INTERVAL,
|
||||
f"{self.name} Bluetooth scanner watchdog",
|
||||
name=f"{self.name} Bluetooth scanner watchdog",
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
@@ -230,7 +230,7 @@ class BaseHaRemoteScanner(BaseHaScanner):
|
||||
self.hass,
|
||||
self._async_expire_devices,
|
||||
timedelta(seconds=30),
|
||||
f"{self.name} Bluetooth scanner device expire",
|
||||
name=f"{self.name} Bluetooth scanner device expire",
|
||||
)
|
||||
cancel_stop = self.hass.bus.async_listen(
|
||||
EVENT_HOMEASSISTANT_STOP, self._async_save_history
|
||||
@@ -345,12 +345,27 @@ class BaseHaRemoteScanner(BaseHaScanner):
|
||||
tx_power=NO_RSSI_VALUE if tx_power is None else tx_power,
|
||||
platform_data=(),
|
||||
)
|
||||
device = BLEDevice(
|
||||
address=address,
|
||||
name=local_name,
|
||||
details=self._details | details,
|
||||
rssi=rssi, # deprecated, will be removed in newer bleak
|
||||
)
|
||||
if prev_discovery:
|
||||
#
|
||||
# Bleak updates the BLEDevice via create_or_update_device.
|
||||
# We need to do the same to ensure integrations that already
|
||||
# have the BLEDevice object get the updated details when they
|
||||
# change.
|
||||
#
|
||||
# https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203
|
||||
#
|
||||
device = prev_device
|
||||
device.name = local_name
|
||||
device.details = self._details | details
|
||||
# pylint: disable-next=protected-access
|
||||
device._rssi = rssi # deprecated, will be removed in newer bleak
|
||||
else:
|
||||
device = BLEDevice(
|
||||
address=address,
|
||||
name=local_name,
|
||||
details=self._details | details,
|
||||
rssi=rssi, # deprecated, will be removed in newer bleak
|
||||
)
|
||||
self._discovered_device_advertisement_datas[address] = (
|
||||
device,
|
||||
advertisement_data,
|
||||
|
||||
@@ -276,7 +276,7 @@ class BluetoothManager:
|
||||
self.hass,
|
||||
self._async_check_unavailable,
|
||||
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS),
|
||||
"Bluetooth manager unavailable tracking",
|
||||
name="Bluetooth manager unavailable tracking",
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"bleak-retry-connector==3.0.2",
|
||||
"bluetooth-adapters==0.15.3",
|
||||
"bluetooth-auto-recovery==1.0.3",
|
||||
"bluetooth-data-tools==0.3.1",
|
||||
"dbus-fast==1.84.2"
|
||||
"bluetooth-data-tools==0.4.0",
|
||||
"dbus-fast==1.85.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class BluetoothCallbackMatcherWithCallback(
|
||||
"""Callback matcher for the bluetooth integration that stores the callback."""
|
||||
|
||||
|
||||
@dataclass(frozen=False)
|
||||
@dataclass(slots=True, frozen=False)
|
||||
class IntegrationMatchHistory:
|
||||
"""Track which fields have been seen."""
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ MANAGER: BluetoothManager | None = None
|
||||
MONOTONIC_TIME: Final = monotonic_time_coarse
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class HaBluetoothConnector:
|
||||
"""Data for how to connect a BLEDevice from a given scanner."""
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
|
||||
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
@dataclasses.dataclass(slots=True, frozen=True)
|
||||
class PassiveBluetoothEntityKey:
|
||||
"""Key for a passive bluetooth entity.
|
||||
|
||||
@@ -36,7 +36,7 @@ class PassiveBluetoothEntityKey:
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
@dataclasses.dataclass(slots=True, frozen=True)
|
||||
class PassiveBluetoothDataUpdate(Generic[_T]):
|
||||
"""Generic bluetooth data."""
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper
|
||||
|
||||
ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner
|
||||
ORIGINAL_BLEAK_CLIENT = bleak.BleakClient
|
||||
ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = (
|
||||
ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE = (
|
||||
bleak_retry_connector.BleakClientWithServiceCache
|
||||
)
|
||||
ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = bleak_retry_connector.BleakClient
|
||||
|
||||
|
||||
def install_multiple_bleak_catcher() -> None:
|
||||
@@ -23,6 +24,7 @@ def install_multiple_bleak_catcher() -> None:
|
||||
bleak.BleakScanner = HaBleakScannerWrapper # type: ignore[misc, assignment]
|
||||
bleak.BleakClient = HaBleakClientWrapper # type: ignore[misc]
|
||||
bleak_retry_connector.BleakClientWithServiceCache = HaBleakClientWithServiceCache # type: ignore[misc,assignment] # noqa: E501
|
||||
bleak_retry_connector.BleakClient = HaBleakClientWrapper # type: ignore[misc] # noqa: E501
|
||||
|
||||
|
||||
def uninstall_multiple_bleak_catcher() -> None:
|
||||
@@ -30,6 +32,9 @@ def uninstall_multiple_bleak_catcher() -> None:
|
||||
bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER # type: ignore[misc]
|
||||
bleak.BleakClient = ORIGINAL_BLEAK_CLIENT # type: ignore[misc]
|
||||
bleak_retry_connector.BleakClientWithServiceCache = ( # type: ignore[misc]
|
||||
ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE
|
||||
)
|
||||
bleak_retry_connector.BleakClient = ( # type: ignore[misc]
|
||||
ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ if TYPE_CHECKING:
|
||||
from .manager import BluetoothManager
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class _HaWrappedBleakBackend:
|
||||
"""Wrap bleak backend to make it usable by Home Assistant."""
|
||||
|
||||
@@ -251,8 +251,10 @@ class HaBleakClientWrapper(BleakClient):
|
||||
assert models.MANAGER is not None
|
||||
manager = models.MANAGER
|
||||
wrapped_backend = self._async_get_best_available_backend_and_device(manager)
|
||||
device = wrapped_backend.device
|
||||
scanner = wrapped_backend.scanner
|
||||
self._backend = wrapped_backend.client(
|
||||
wrapped_backend.device,
|
||||
device,
|
||||
disconnected_callback=self._make_disconnected_callback(
|
||||
self.__disconnected_callback
|
||||
),
|
||||
@@ -261,8 +263,9 @@ class HaBleakClientWrapper(BleakClient):
|
||||
)
|
||||
if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
# Only lookup the description if we are going to log it
|
||||
description = ble_device_description(wrapped_backend.device)
|
||||
rssi = wrapped_backend.device.rssi
|
||||
description = ble_device_description(device)
|
||||
_, adv = scanner.discovered_devices_and_advertisement_data[device.address]
|
||||
rssi = adv.rssi
|
||||
_LOGGER.debug("%s: Connecting (last rssi: %s)", description, rssi)
|
||||
connected = None
|
||||
try:
|
||||
@@ -271,11 +274,11 @@ class HaBleakClientWrapper(BleakClient):
|
||||
# If we failed to connect and its a local adapter (no source)
|
||||
# we release the connection slot
|
||||
if not connected:
|
||||
self.__connect_failures[wrapped_backend.scanner] = (
|
||||
self.__connect_failures.get(wrapped_backend.scanner, 0) + 1
|
||||
self.__connect_failures[scanner] = (
|
||||
self.__connect_failures.get(scanner, 0) + 1
|
||||
)
|
||||
if not wrapped_backend.source:
|
||||
manager.async_release_connection_slot(wrapped_backend.device)
|
||||
manager.async_release_connection_slot(device)
|
||||
|
||||
if debug_logging:
|
||||
_LOGGER.debug("%s: Connected (last rssi: %s)", description, rssi)
|
||||
|
||||
@@ -70,6 +70,7 @@ async def async_setup_scanner( # noqa: C901
|
||||
yaml_path = hass.config.path(YAML_DEVICES)
|
||||
devs_to_track: set[str] = set()
|
||||
devs_no_track: set[str] = set()
|
||||
devs_advertise_time: dict[str, float] = {}
|
||||
devs_track_battery = {}
|
||||
interval: timedelta = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
|
||||
# if track new devices is true discover new devices
|
||||
@@ -178,6 +179,7 @@ async def async_setup_scanner( # noqa: C901
|
||||
"""Update from a ble callback."""
|
||||
mac = service_info.address
|
||||
if mac in devs_to_track:
|
||||
devs_advertise_time[mac] = service_info.time
|
||||
now = dt_util.utcnow()
|
||||
hass.async_create_task(async_see_device(mac, service_info.name))
|
||||
if (
|
||||
@@ -205,7 +207,9 @@ async def async_setup_scanner( # noqa: C901
|
||||
# there have been no callbacks because the RSSI or
|
||||
# other properties have not changed.
|
||||
for service_info in bluetooth.async_discovered_service_info(hass, False):
|
||||
_async_update_ble(service_info, bluetooth.BluetoothChange.ADVERTISEMENT)
|
||||
# Only call _async_update_ble if the advertisement time has changed
|
||||
if service_info.time != devs_advertise_time.get(service_info.address):
|
||||
_async_update_ble(service_info, bluetooth.BluetoothChange.ADVERTISEMENT)
|
||||
|
||||
cancels = [
|
||||
bluetooth.async_register_callback(
|
||||
|
||||
@@ -41,6 +41,7 @@ PLATFORMS = [
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.LOCK,
|
||||
Platform.NOTIFY,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Select platform for BMW."""
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.charging_profile import ChargingMode
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE, UnitOfElectricCurrent
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import BMWBaseEntity
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BMWRequiredKeysMixin:
|
||||
"""Mixin for required keys."""
|
||||
|
||||
current_option: Callable[[MyBMWVehicle], str]
|
||||
remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin):
|
||||
"""Describes BMW sensor entity."""
|
||||
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
|
||||
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
|
||||
|
||||
|
||||
SELECT_TYPES: dict[str, BMWSelectEntityDescription] = {
|
||||
# --- Generic ---
|
||||
"target_soc": BMWSelectEntityDescription(
|
||||
key="target_soc",
|
||||
name="Target SoC",
|
||||
is_available=lambda v: v.is_remote_set_target_soc_enabled,
|
||||
options=[str(i * 5 + 20) for i in range(17)],
|
||||
current_option=lambda v: str(v.fuel_and_battery.charging_target),
|
||||
remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update(
|
||||
target_soc=int(o)
|
||||
),
|
||||
icon="mdi:battery-charging-medium",
|
||||
unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
"ac_limit": BMWSelectEntityDescription(
|
||||
key="ac_limit",
|
||||
name="AC Charging Limit",
|
||||
is_available=lambda v: v.is_remote_set_ac_limit_enabled,
|
||||
dynamic_options=lambda v: [
|
||||
str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr]
|
||||
],
|
||||
current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr]
|
||||
remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update(
|
||||
ac_limit=int(o)
|
||||
),
|
||||
icon="mdi:current-ac",
|
||||
unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
),
|
||||
"charging_mode": BMWSelectEntityDescription(
|
||||
key="charging_mode",
|
||||
name="Charging Mode",
|
||||
is_available=lambda v: v.is_charging_plan_supported,
|
||||
options=[c.value for c in ChargingMode if c != ChargingMode.UNKNOWN],
|
||||
current_option=lambda v: str(v.charging_profile.charging_mode.value), # type: ignore[union-attr]
|
||||
remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update(
|
||||
charging_mode=ChargingMode(o)
|
||||
),
|
||||
icon="mdi:vector-point-select",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW lock from config entry."""
|
||||
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities: list[BMWSelect] = []
|
||||
|
||||
for vehicle in coordinator.account.vehicles:
|
||||
if not coordinator.read_only:
|
||||
entities.extend(
|
||||
[
|
||||
BMWSelect(coordinator, vehicle, description)
|
||||
for description in SELECT_TYPES.values()
|
||||
if description.is_available(vehicle)
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWSelect(BMWBaseEntity, SelectEntity):
|
||||
"""Representation of BMW select entity."""
|
||||
|
||||
entity_description: BMWSelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize an BMW select."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
if description.dynamic_options:
|
||||
self._attr_options = description.dynamic_options(vehicle)
|
||||
self._attr_current_option = description.current_option(vehicle)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
_LOGGER.debug(
|
||||
"Updating select '%s' of %s", self.entity_description.key, self.vehicle.name
|
||||
)
|
||||
self._attr_current_option = self.entity_description.current_option(self.vehicle)
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Update to the vehicle."""
|
||||
_LOGGER.debug(
|
||||
"Executing '%s' on vehicle '%s' to value '%s'",
|
||||
self.entity_description.key,
|
||||
self.vehicle.vin,
|
||||
option,
|
||||
)
|
||||
await self.entity_description.remote_service(self.vehicle, option)
|
||||
@@ -17,9 +17,9 @@ from homeassistant.const import (
|
||||
ATTR_SW_VERSION,
|
||||
ATTR_VIA_DEVICE,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import CALLBACK_TYPE, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from .const import DOMAIN
|
||||
from .utils import BondDevice, BondHub
|
||||
@@ -27,6 +27,7 @@ from .utils import BondDevice, BondHub
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_FALLBACK_SCAN_INTERVAL = timedelta(seconds=10)
|
||||
_BPUP_ALIVE_SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
class BondEntity(Entity):
|
||||
@@ -65,6 +66,7 @@ class BondEntity(Entity):
|
||||
self._attr_name = device.name
|
||||
self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state
|
||||
self._apply_state()
|
||||
self._bpup_polling_fallback: CALLBACK_TYPE | None = None
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
@@ -100,12 +102,13 @@ class BondEntity(Entity):
|
||||
return device_info
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Fetch assumed state of the cover from the hub using API."""
|
||||
"""Perform a manual update from API."""
|
||||
await self._async_update_from_api()
|
||||
|
||||
@callback
|
||||
def _async_update_if_bpup_not_alive(self, now: datetime) -> None:
|
||||
"""Fetch via the API if BPUP is not alive."""
|
||||
self._async_schedule_bpup_alive_or_poll()
|
||||
if (
|
||||
self.hass.is_stopping
|
||||
or self._bpup_subs.alive
|
||||
@@ -172,16 +175,22 @@ class BondEntity(Entity):
|
||||
"""Subscribe to BPUP and start polling."""
|
||||
await super().async_added_to_hass()
|
||||
self._bpup_subs.subscribe(self._device_id, self._async_bpup_callback)
|
||||
self.async_on_remove(
|
||||
async_track_time_interval(
|
||||
self.hass,
|
||||
self._async_update_if_bpup_not_alive,
|
||||
_FALLBACK_SCAN_INTERVAL,
|
||||
f"Bond {self.entity_id} fallback polling",
|
||||
)
|
||||
self._async_schedule_bpup_alive_or_poll()
|
||||
|
||||
@callback
|
||||
def _async_schedule_bpup_alive_or_poll(self) -> None:
|
||||
"""Schedule the BPUP alive or poll."""
|
||||
alive = self._bpup_subs.alive
|
||||
self._bpup_polling_fallback = async_call_later(
|
||||
self.hass,
|
||||
_BPUP_ALIVE_SCAN_INTERVAL if alive else _FALLBACK_SCAN_INTERVAL,
|
||||
self._async_update_if_bpup_not_alive,
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unsubscribe from BPUP data on remove."""
|
||||
await super().async_will_remove_from_hass()
|
||||
self._bpup_subs.unsubscribe(self._device_id, self._async_bpup_callback)
|
||||
if self._bpup_polling_fallback:
|
||||
self._bpup_polling_fallback()
|
||||
self._bpup_polling_fallback = None
|
||||
|
||||
@@ -36,14 +36,14 @@ class BraviaTVButtonDescription(
|
||||
BUTTONS: tuple[BraviaTVButtonDescription, ...] = (
|
||||
BraviaTVButtonDescription(
|
||||
key="reboot",
|
||||
name="Reboot",
|
||||
translation_key="restart",
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda coordinator: coordinator.async_reboot_device(),
|
||||
),
|
||||
BraviaTVButtonDescription(
|
||||
key="terminate_apps",
|
||||
name="Terminate apps",
|
||||
translation_key="terminate_apps",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda coordinator: coordinator.async_terminate_apps(),
|
||||
),
|
||||
|
||||
@@ -44,5 +44,15 @@
|
||||
"not_bravia_device": "The device is not a Bravia TV.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"restart": {
|
||||
"name": "[%key:component::button::entity_component::restart::name%]"
|
||||
},
|
||||
"terminate_apps": {
|
||||
"name": "Terminate apps"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ from .heartbeat import BroadlinkHeartbeat
|
||||
class BroadlinkData:
|
||||
"""Class for sharing data within the Broadlink integration."""
|
||||
|
||||
devices: dict = field(default_factory=dict)
|
||||
devices: dict[str, BroadlinkDevice] = field(default_factory=dict)
|
||||
platforms: dict = field(default_factory=dict)
|
||||
heartbeat: BroadlinkHeartbeat | None = None
|
||||
|
||||
@@ -29,7 +29,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a Broadlink device from a config entry."""
|
||||
data = hass.data[DOMAIN]
|
||||
data: BroadlinkData = hass.data[DOMAIN]
|
||||
|
||||
if data.heartbeat is None:
|
||||
data.heartbeat = BroadlinkHeartbeat(hass)
|
||||
@@ -41,12 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
data = hass.data[DOMAIN]
|
||||
data: BroadlinkData = hass.data[DOMAIN]
|
||||
|
||||
device = data.devices.pop(entry.entry_id)
|
||||
result = await device.async_unload()
|
||||
|
||||
if not data.devices:
|
||||
if data.heartbeat and not data.devices:
|
||||
await data.heartbeat.async_unload()
|
||||
data.heartbeat = None
|
||||
|
||||
|
||||
@@ -13,8 +13,15 @@ from broadlink.exceptions import (
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
CONF_NAME,
|
||||
CONF_TIMEOUT,
|
||||
CONF_TYPE,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
@@ -24,7 +31,7 @@ from .updater import get_update_manager
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_domains(device_type):
|
||||
def get_domains(device_type: str) -> set[Platform]:
|
||||
"""Return the domains available for a device type."""
|
||||
return {d for d, t in DOMAINS_AND_TYPES.items() if device_type in t}
|
||||
|
||||
@@ -32,33 +39,34 @@ def get_domains(device_type):
|
||||
class BroadlinkDevice:
|
||||
"""Manages a Broadlink device."""
|
||||
|
||||
def __init__(self, hass, config):
|
||||
api: blk.Device
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
|
||||
"""Initialize the device."""
|
||||
self.hass = hass
|
||||
self.config = config
|
||||
self.api = None
|
||||
self.update_manager = None
|
||||
self.fw_version = None
|
||||
self.authorized = None
|
||||
self.reset_jobs = []
|
||||
self.fw_version: int | None = None
|
||||
self.authorized: bool | None = None
|
||||
self.reset_jobs: list[CALLBACK_TYPE] = []
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
def name(self) -> str:
|
||||
"""Return the name of the device."""
|
||||
return self.config.title
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return the unique id of the device."""
|
||||
return self.config.unique_id
|
||||
|
||||
@property
|
||||
def mac_address(self):
|
||||
def mac_address(self) -> str:
|
||||
"""Return the mac address of the device."""
|
||||
return self.config.data[CONF_MAC]
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool | None:
|
||||
"""Return True if the device is available."""
|
||||
if self.update_manager is None:
|
||||
return False
|
||||
@@ -77,14 +85,14 @@ class BroadlinkDevice:
|
||||
device_registry.async_update_device(device_entry.id, name=entry.title)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
def _get_firmware_version(self):
|
||||
def _get_firmware_version(self) -> int | None:
|
||||
"""Get firmware version."""
|
||||
self.api.auth()
|
||||
with suppress(BroadlinkException, OSError):
|
||||
return self.api.get_fwversion()
|
||||
return None
|
||||
|
||||
async def async_setup(self):
|
||||
async def async_setup(self) -> bool:
|
||||
"""Set up the device and related entities."""
|
||||
config = self.config
|
||||
|
||||
@@ -132,7 +140,7 @@ class BroadlinkDevice:
|
||||
|
||||
return True
|
||||
|
||||
async def async_unload(self):
|
||||
async def async_unload(self) -> bool:
|
||||
"""Unload the device and related entities."""
|
||||
if self.update_manager is None:
|
||||
return True
|
||||
@@ -144,7 +152,7 @@ class BroadlinkDevice:
|
||||
self.config, get_domains(self.api.type)
|
||||
)
|
||||
|
||||
async def async_auth(self):
|
||||
async def async_auth(self) -> bool:
|
||||
"""Authenticate to the device."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self.api.auth)
|
||||
@@ -167,7 +175,7 @@ class BroadlinkDevice:
|
||||
raise
|
||||
return await self.hass.async_add_executor_job(request)
|
||||
|
||||
async def _async_handle_auth_error(self):
|
||||
async def _async_handle_auth_error(self) -> None:
|
||||
"""Handle an authentication error."""
|
||||
if self.authorized is False:
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import broadlink as blk
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.helpers import event
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -21,12 +22,12 @@ class BroadlinkHeartbeat:
|
||||
|
||||
HEARTBEAT_INTERVAL = dt.timedelta(minutes=2)
|
||||
|
||||
def __init__(self, hass):
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the heartbeat."""
|
||||
self._hass = hass
|
||||
self._unsubscribe = None
|
||||
self._unsubscribe: CALLBACK_TYPE | None = None
|
||||
|
||||
async def async_setup(self):
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the heartbeat."""
|
||||
if self._unsubscribe is None:
|
||||
await self.async_heartbeat(dt.datetime.now())
|
||||
@@ -34,21 +35,21 @@ class BroadlinkHeartbeat:
|
||||
self._hass, self.async_heartbeat, self.HEARTBEAT_INTERVAL
|
||||
)
|
||||
|
||||
async def async_unload(self):
|
||||
async def async_unload(self) -> None:
|
||||
"""Unload the heartbeat."""
|
||||
if self._unsubscribe is not None:
|
||||
self._unsubscribe()
|
||||
self._unsubscribe = None
|
||||
|
||||
async def async_heartbeat(self, now):
|
||||
async def async_heartbeat(self, _: dt.datetime) -> None:
|
||||
"""Send packets to feed watchdog timers."""
|
||||
hass = self._hass
|
||||
config_entries = hass.config_entries.async_entries(DOMAIN)
|
||||
hosts = {entry.data[CONF_HOST] for entry in config_entries}
|
||||
hosts: set[str] = {entry.data[CONF_HOST] for entry in config_entries}
|
||||
await hass.async_add_executor_job(self.heartbeat, hosts)
|
||||
|
||||
@staticmethod
|
||||
def heartbeat(hosts):
|
||||
def heartbeat(hosts: set[str]) -> None:
|
||||
"""Send packets to feed watchdog timers."""
|
||||
for host in hosts:
|
||||
try:
|
||||
|
||||
@@ -25,61 +25,61 @@ from .entity import BroadlinkEntity
|
||||
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
translation_key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="air_quality",
|
||||
name="Air quality",
|
||||
translation_key="air_quality",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="humidity",
|
||||
name="Humidity",
|
||||
translation_key="humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="light",
|
||||
name="Light",
|
||||
translation_key="light",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="noise",
|
||||
name="Noise",
|
||||
translation_key="noise",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="power",
|
||||
name="Current power",
|
||||
translation_key="power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="volt",
|
||||
name="Voltage",
|
||||
translation_key="voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current",
|
||||
name="Current",
|
||||
translation_key="current",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="overload",
|
||||
name="Overload",
|
||||
translation_key="overload",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="totalconsum",
|
||||
name="Total consumption",
|
||||
translation_key="total_consumption",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
|
||||
@@ -43,5 +43,39 @@
|
||||
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"temperature": {
|
||||
"name": "[%key:component::sensor::entity_component::temperature::name%]"
|
||||
},
|
||||
"air_quality": {
|
||||
"name": "[%key:component::sensor::entity_component::aqi::name%]"
|
||||
},
|
||||
"humidity": {
|
||||
"name": "[%key:component::sensor::entity_component::humidity::name%]"
|
||||
},
|
||||
"light": {
|
||||
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
|
||||
},
|
||||
"noise": {
|
||||
"name": "Noise"
|
||||
},
|
||||
"power": {
|
||||
"name": "[%key:component::sensor::entity_component::power::name%]"
|
||||
},
|
||||
"voltage": {
|
||||
"name": "[%key:component::sensor::entity_component::voltage::name%]"
|
||||
},
|
||||
"current": {
|
||||
"name": "[%key:component::sensor::entity_component::current::name%]"
|
||||
},
|
||||
"overload": {
|
||||
"name": "Overload"
|
||||
},
|
||||
"total_consumption": {
|
||||
"name": "Total consumption"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,14 +53,14 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="status",
|
||||
icon="mdi:printer",
|
||||
name="Status",
|
||||
translation_key="status",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value=lambda data: data.status,
|
||||
),
|
||||
BrotherSensorEntityDescription(
|
||||
key="page_counter",
|
||||
icon="mdi:file-document-outline",
|
||||
name="Page counter",
|
||||
translation_key="page_counter",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -69,7 +69,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="bw_counter",
|
||||
icon="mdi:file-document-outline",
|
||||
name="B/W counter",
|
||||
translation_key="bw_pages",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -78,7 +78,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="color_counter",
|
||||
icon="mdi:file-document-outline",
|
||||
name="Color counter",
|
||||
translation_key="color_pages",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -87,7 +87,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="duplex_unit_pages_counter",
|
||||
icon="mdi:file-document-outline",
|
||||
name="Duplex unit pages counter",
|
||||
translation_key="duplex_unit_page_counter",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -96,7 +96,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="drum_remaining_life",
|
||||
icon="mdi:chart-donut",
|
||||
name="Drum remaining life",
|
||||
translation_key="drum_remaining_life",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -105,7 +105,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="drum_remaining_pages",
|
||||
icon="mdi:chart-donut",
|
||||
name="Drum remaining pages",
|
||||
translation_key="drum_remaining_pages",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -114,7 +114,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="drum_counter",
|
||||
icon="mdi:chart-donut",
|
||||
name="Drum counter",
|
||||
translation_key="drum_page_counter",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -123,7 +123,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="black_drum_remaining_life",
|
||||
icon="mdi:chart-donut",
|
||||
name="Black drum remaining life",
|
||||
translation_key="black_drum_remaining_life",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -132,7 +132,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="black_drum_remaining_pages",
|
||||
icon="mdi:chart-donut",
|
||||
name="Black drum remaining pages",
|
||||
translation_key="black_drum_remaining_pages",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -141,7 +141,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="black_drum_counter",
|
||||
icon="mdi:chart-donut",
|
||||
name="Black drum counter",
|
||||
translation_key="black_drum_page_counter",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -150,7 +150,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="cyan_drum_remaining_life",
|
||||
icon="mdi:chart-donut",
|
||||
name="Cyan drum remaining life",
|
||||
translation_key="cyan_drum_remaining_life",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -159,7 +159,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="cyan_drum_remaining_pages",
|
||||
icon="mdi:chart-donut",
|
||||
name="Cyan drum remaining pages",
|
||||
translation_key="cyan_drum_remaining_pages",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -168,7 +168,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="cyan_drum_counter",
|
||||
icon="mdi:chart-donut",
|
||||
name="Cyan drum counter",
|
||||
translation_key="cyan_drum_page_counter",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -177,7 +177,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="magenta_drum_remaining_life",
|
||||
icon="mdi:chart-donut",
|
||||
name="Magenta drum remaining life",
|
||||
translation_key="magenta_drum_remaining_life",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -186,7 +186,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="magenta_drum_remaining_pages",
|
||||
icon="mdi:chart-donut",
|
||||
name="Magenta drum remaining pages",
|
||||
translation_key="magenta_drum_remaining_pages",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -195,7 +195,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="magenta_drum_counter",
|
||||
icon="mdi:chart-donut",
|
||||
name="Magenta drum counter",
|
||||
translation_key="magenta_drum_page_counter",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -204,7 +204,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="yellow_drum_remaining_life",
|
||||
icon="mdi:chart-donut",
|
||||
name="Yellow drum remaining life",
|
||||
translation_key="yellow_drum_remaining_life",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -213,7 +213,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="yellow_drum_remaining_pages",
|
||||
icon="mdi:chart-donut",
|
||||
name="Yellow drum remaining pages",
|
||||
translation_key="yellow_drum_remaining_pages",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -222,7 +222,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="yellow_drum_counter",
|
||||
icon="mdi:chart-donut",
|
||||
name="Yellow drum counter",
|
||||
translation_key="yellow_drum_page_counter",
|
||||
native_unit_of_measurement=UNIT_PAGES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -231,7 +231,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="belt_unit_remaining_life",
|
||||
icon="mdi:current-ac",
|
||||
name="Belt unit remaining life",
|
||||
translation_key="belt_unit_remaining_life",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -240,7 +240,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="fuser_remaining_life",
|
||||
icon="mdi:water-outline",
|
||||
name="Fuser remaining life",
|
||||
translation_key="fuser_remaining_life",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -249,7 +249,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="laser_remaining_life",
|
||||
icon="mdi:spotlight-beam",
|
||||
name="Laser remaining life",
|
||||
translation_key="laser_remaining_life",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -258,7 +258,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="pf_kit_1_remaining_life",
|
||||
icon="mdi:printer-3d",
|
||||
name="PF Kit 1 remaining life",
|
||||
translation_key="pf_kit_1_remaining_life",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -267,7 +267,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="pf_kit_mp_remaining_life",
|
||||
icon="mdi:printer-3d",
|
||||
name="PF Kit MP remaining life",
|
||||
translation_key="pf_kit_mp_remaining_life",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -276,7 +276,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="black_toner_remaining",
|
||||
icon="mdi:printer-3d-nozzle",
|
||||
name="Black toner remaining",
|
||||
translation_key="black_toner_remaining",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -285,7 +285,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="cyan_toner_remaining",
|
||||
icon="mdi:printer-3d-nozzle",
|
||||
name="Cyan toner remaining",
|
||||
translation_key="cyan_toner_remaining",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -294,7 +294,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="magenta_toner_remaining",
|
||||
icon="mdi:printer-3d-nozzle",
|
||||
name="Magenta toner remaining",
|
||||
translation_key="magenta_toner_remaining",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -303,7 +303,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="yellow_toner_remaining",
|
||||
icon="mdi:printer-3d-nozzle",
|
||||
name="Yellow toner remaining",
|
||||
translation_key="yellow_toner_remaining",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -312,7 +312,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="black_ink_remaining",
|
||||
icon="mdi:printer-3d-nozzle",
|
||||
name="Black ink remaining",
|
||||
translation_key="black_ink_remaining",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -321,7 +321,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="cyan_ink_remaining",
|
||||
icon="mdi:printer-3d-nozzle",
|
||||
name="Cyan ink remaining",
|
||||
translation_key="cyan_ink_remaining",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -330,7 +330,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="magenta_ink_remaining",
|
||||
icon="mdi:printer-3d-nozzle",
|
||||
name="Magenta ink remaining",
|
||||
translation_key="magenta_ink_remaining",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -339,7 +339,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
BrotherSensorEntityDescription(
|
||||
key="yellow_ink_remaining",
|
||||
icon="mdi:printer-3d-nozzle",
|
||||
name="Yellow ink remaining",
|
||||
translation_key="yellow_ink_remaining",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -347,7 +347,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||
),
|
||||
BrotherSensorEntityDescription(
|
||||
key="uptime",
|
||||
name="Uptime",
|
||||
translation_key="last_restart",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
|
||||
@@ -25,5 +25,111 @@
|
||||
"unsupported_model": "This printer model is not supported.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"status": {
|
||||
"name": "Status"
|
||||
},
|
||||
"page_counter": {
|
||||
"name": "Page counter"
|
||||
},
|
||||
"bw_pages": {
|
||||
"name": "B/W pages"
|
||||
},
|
||||
"color_pages": {
|
||||
"name": "Color pages"
|
||||
},
|
||||
"duplex_unit_page_counter": {
|
||||
"name": "Duplex unit page counter"
|
||||
},
|
||||
"drum_remaining_life": {
|
||||
"name": "Drum remaining life"
|
||||
},
|
||||
"drum_remaining_pages": {
|
||||
"name": "Drum remaining pages"
|
||||
},
|
||||
"drum_page_counter": {
|
||||
"name": "Drum page counter"
|
||||
},
|
||||
"black_drum_remaining_life": {
|
||||
"name": "Black drum remaining life"
|
||||
},
|
||||
"black_drum_remaining_pages": {
|
||||
"name": "Black drum remaining pages"
|
||||
},
|
||||
"black_drum_page_counter": {
|
||||
"name": "Black drum page counter"
|
||||
},
|
||||
"cyan_drum_remaining_life": {
|
||||
"name": "Cyan drum remaining life"
|
||||
},
|
||||
"cyan_drum_remaining_pages": {
|
||||
"name": "Cyan drum remaining pages"
|
||||
},
|
||||
"cyan_drum_page_counter": {
|
||||
"name": "Cyan drum page counter"
|
||||
},
|
||||
"magenta_drum_remaining_life": {
|
||||
"name": "Magenta drum remaining life"
|
||||
},
|
||||
"magenta_drum_remaining_pages": {
|
||||
"name": "Magenta drum remaining pages"
|
||||
},
|
||||
"magenta_drum_page_counter": {
|
||||
"name": "Magenta drum page counter"
|
||||
},
|
||||
"yellow_drum_remaining_life": {
|
||||
"name": "Yellow drum remaining life"
|
||||
},
|
||||
"yellow_drum_remaining_pages": {
|
||||
"name": "Yellow drum remaining pages"
|
||||
},
|
||||
"yellow_drum_page_counter": {
|
||||
"name": "Yellow drum page counter"
|
||||
},
|
||||
"belt_unit_remaining_life": {
|
||||
"name": "Belt unit remaining life"
|
||||
},
|
||||
"fuser_remaining_life": {
|
||||
"name": "Fuser remaining life"
|
||||
},
|
||||
"laser_remaining_life": {
|
||||
"name": "Laser remaining life"
|
||||
},
|
||||
"pf_kit_1_remaining_life": {
|
||||
"name": "PF Kit 1 remaining life"
|
||||
},
|
||||
"pf_kit_mp_remaining_life": {
|
||||
"name": "PF Kit MP remaining life"
|
||||
},
|
||||
"black_toner_remaining": {
|
||||
"name": "Black toner remaining"
|
||||
},
|
||||
"cyan_toner_remaining": {
|
||||
"name": "Cyan toner remaining"
|
||||
},
|
||||
"magenta_toner_remaining": {
|
||||
"name": "Magenta toner remaining"
|
||||
},
|
||||
"yellow_toner_remaining": {
|
||||
"name": "Yellow toner remaining"
|
||||
},
|
||||
"black_ink_remaining": {
|
||||
"name": "Black ink remaining"
|
||||
},
|
||||
"cyan_ink_remaining": {
|
||||
"name": "Cyan ink remaining"
|
||||
},
|
||||
"magenta_ink_remaining": {
|
||||
"name": "Magenta ink remaining"
|
||||
},
|
||||
"yellow_ink_remaining": {
|
||||
"name": "Yellow ink remaining"
|
||||
},
|
||||
"last_restart": {
|
||||
"name": "Last restart"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,21 @@
|
||||
"""The brottsplatskartan component."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import PLATFORMS
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up brottsplatskartan from a config entry."""
|
||||
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload brottsplatskartan config entry."""
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user