mirror of
				https://github.com/home-assistant/core.git
				synced 2025-10-31 14:39:27 +00:00 
			
		
		
		
	Compare commits
	
		
			595 Commits
		
	
	
		
			media-sour
			...
			mqtt-subsc
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | a784ec6454 | ||
|   | 8c8b1df11f | ||
|   | aabcff9653 | ||
|   | 5613be3980 | ||
|   | fbcf0eb94c | ||
|   | 1c7b9cc354 | ||
|   | 75e900606e | ||
|   | 7c665c53b5 | ||
|   | f72047eb02 | ||
|   | ade424c074 | ||
|   | 5ad805de3c | ||
|   | ece77cf620 | ||
|   | 7eaa559056 | ||
|   | 08a9377373 | ||
|   | a2837e6aee | ||
|   | fa03f6194d | ||
|   | d2851ea1df | ||
|   | 72f8ac7857 | ||
|   | 77a267bc2f | ||
|   | ad238daadc | ||
|   | 42370ba203 | ||
|   | d9691c2a3b | ||
|   | 66cca981a9 | ||
|   | 9640ebb593 | ||
|   | 645f32fd65 | ||
|   | cb6e65f972 | ||
|   | 425bdc0ba6 | ||
|   | c36341e51f | ||
|   | 553d896899 | ||
|   | ac79b3072e | ||
|   | c0aa9bfd4b | ||
|   | e97100028d | ||
|   | da89617432 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e6203dffd3 | ||
|   | c13cfe9c37 | ||
|   | 2447df9341 | ||
|   | 1c1fbe0ec1 | ||
|   | 4a6d2017fd | ||
|   | b4997a52df | ||
|   | 464dec1dcb | ||
|   | 85506ac78a | ||
|   | 6d97355b42 | ||
|   | f9e75c616a | ||
|   | a821d02dfb | ||
|   | e05169c7a4 | ||
|   | 1cc3431529 | ||
|   | 4ba765f265 | ||
|   | 50a7af4179 | ||
|   | e0a2116e88 | ||
|   | d8e1ed5f4a | ||
|   | f1b8e8a963 | ||
|   | 9a9fd44c62 | ||
|   | bc3fe7a18e | ||
|   | 19f3559345 | ||
|   | fad0e23797 | ||
|   | 7f931e4d70 | ||
|   | a04835629b | ||
|   | 78cd80746d | ||
|   | 9ac93920d8 | ||
|   | 1818fce1ae | ||
|   | f524edc4b9 | ||
|   | 19f990ed31 | ||
|   | 5d83c82b81 | ||
|   | d63d154457 | ||
|   | 933b15ce36 | ||
|   | 6ec7b63ebe | ||
|   | 26bfbc55e9 | ||
|   | d75ca0f5f3 | ||
|   | fed8f137e9 | ||
|   | f44d65e023 | ||
|   | a270bd76de | ||
|   | 9209e419ec | ||
|   | 98f8f15e90 | ||
|   | b2a2868afd | ||
|   | 0d4737d360 | ||
|   | 2b370a0eca | ||
|   | 618fe81207 | ||
|   | c0fe4861f9 | ||
|   | dfd33fdab1 | ||
|   | cceee05c15 | ||
|   | f560d2a05e | ||
|   | 3601cff88e | ||
|   | ca5c0a759f | ||
|   | 6f9e6909ce | ||
|   | ccf563437b | ||
|   | 78e97428fd | ||
|   | 8b4c730993 | ||
|   | 0a071a13e2 | ||
|   | ab80991eac | ||
|   | ee7262efb4 | ||
|   | ea5a52cdc8 | ||
|   | 31fe0322ab | ||
|   | e8e0eabb99 | ||
|   | 1629dad1a8 | ||
|   | d9baad530a | ||
|   | 4a1d00e59a | ||
|   | 437e4e027c | ||
|   | 3726f7eca9 | ||
|   | c943cf515c | ||
|   | 3b0c2a7e56 | ||
|   | 6ebaa9cd1d | ||
|   | f81c32f6ea | ||
|   | c0cd7a1a62 | ||
|   | 7a61c818c6 | ||
|   | 2800625bcf | ||
|   | cfec998221 | ||
|   | 7203cffbd7 | ||
|   | 23397ef6a9 | ||
|   | 0e154635ff | ||
|   | 2e6e518722 | ||
|   | e0cded97c7 | ||
|   | 87a6a029bb | ||
|   | 1cc3c22d3f | ||
|   | 2341d1d965 | ||
|   | a0bae9485c | ||
|   | f281b0fc6b | ||
|   | 6f89fe81cc | ||
|   | 34f6ead7a1 | ||
|   | 8985527a87 | ||
|   | bd87a3aa4d | ||
|   | 768a505904 | ||
|   | d97c1f0fc3 | ||
|   | c3fcd34d4c | ||
|   | 44d9eaea95 | ||
|   | 0f34f5139a | ||
|   | 2afb1a673d | ||
|   | c2f7f29630 | ||
|   | b01f5dd24b | ||
|   | 0cda0c449f | ||
|   | 40fdf12bc9 | ||
|   | 3939a80302 | ||
|   | d32a102613 | ||
|   | 20949d39c4 | ||
|   | 310a0c8d13 | ||
|   | c9e80ac7e9 | ||
|   | 5df4e9e1cf | ||
|   | 4022ee74e8 | ||
|   | 80a4115c44 | ||
|   | ce548efd80 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2edf622b41 | ||
|   | 66ac9078aa | ||
|   | ba75f18f5a | ||
|   | 8ee2ece03e | ||
|   | 7060ab8c44 | ||
|   | 85d8244b8a | ||
|   | 3f9421ab08 | ||
|   | 2f3fbf00b7 | ||
|   | d595ec8a07 | ||
|   | 4ff5462cc4 | ||
|   | 404f95b442 | ||
|   | 89cf784022 | ||
|   | 02142f352d | ||
|   | ec3dd7d1e5 | ||
|   | 7355799030 | ||
|   | 982166df3c | ||
|   | c7d3512ad2 | ||
|   | ada6f7b3fb | ||
|   | 78e16495bd | ||
|   | 12085e6152 | ||
|   | 6764463689 | ||
|   | 7055276665 | ||
|   | 71b3ebd15a | ||
|   | b87910e596 | ||
|   | e19bfd670b | ||
|   | 7b3c96e80b | ||
|   | 01ff3cf9d9 | ||
|   | d66da0c10d | ||
|   | 3491bb1b40 | ||
|   | 3bf995eb71 | ||
|   | 2169ce1722 | ||
|   | 275e9485e9 | ||
|   | 95198ae540 | ||
|   | aed2d3899d | ||
|   | 4011d62ac7 | ||
|   | d2aa0573de | ||
|   | 571b2e3ab6 | ||
|   | a7f48360b7 | ||
|   | 22f2f8680a | ||
|   | d92004a9e7 | ||
|   | 64875894d6 | ||
|   | 3f7a288526 | ||
|   | a2a067a81c | ||
|   | f9f61b8da7 | ||
|   | cd69b82fc9 | ||
|   | d20631598e | ||
|   | 229ebe16f3 | ||
|   | a172f67d37 | ||
|   | ee4a1de566 | ||
|   | 7ab99c028c | ||
|   | 0e1d12b1ae | ||
|   | e090ddd761 | ||
|   | 9721ce6877 | ||
|   | 8dde94f421 | ||
|   | f5f6b22af1 | ||
|   | f8a93b6561 | ||
|   | 840a03f048 | ||
|   | 85f3b5ce78 | ||
|   | f4284fec2f | ||
|   | 3a89b3152f | ||
|   | a0356328c3 | ||
|   | 4b6f37b1d7 | ||
|   | 716705fb5a | ||
|   | d246836480 | ||
|   | 6ee2b82d15 | ||
|   | 73ff8d36a5 | ||
|   | 1397def3b8 | ||
|   | d443529041 | ||
|   | 373bb20f1b | ||
|   | 3b44cce6dc | ||
|   | 46056fe45b | ||
|   | 1816c190b2 | ||
|   | 00abaee6b3 | ||
|   | 3a301f54e0 | ||
|   | 762accbd6d | ||
|   | e0422d7d34 | ||
|   | 6ba2057a88 | ||
|   | 752969bce5 | ||
|   | efbdfd2954 | ||
|   | bb7a177a5d | ||
|   | 9b56ca8cde | ||
|   | b0a08782e0 | ||
|   | 6c9955f220 | ||
|   | f56b94c0f9 | ||
|   | 3cf035820b | ||
|   | 99a796d066 | ||
|   | 1cd1b1aba8 | ||
|   | 4131c14629 | ||
|   | c2acda5796 | ||
|   | 4806e7e9d9 | ||
|   | 76606fd44f | ||
|   | 2983f1a3b6 | ||
|   | 8019779b3a | ||
|   | 62cdcbf422 | ||
|   | b12a5a36e1 | ||
|   | e32763e464 | ||
|   | b85cf3f9d2 | ||
|   | 3777bcc2af | ||
|   | 9a29cc53ef | ||
|   | 52cde48ff0 | ||
|   | bf1da35303 | ||
|   | 55d5e769b2 | ||
|   | c1bf11da34 | ||
|   | 3c20325b37 | ||
|   | 6cd1283b00 | ||
|   | dde60cdecb | ||
|   | f03b16bdf8 | ||
|   | fd8ccb8d8f | ||
|   | d76e947021 | ||
|   | c91ed96543 | ||
|   | b164531ba8 | ||
|   | 7c623a8704 | ||
|   | 7ae3340336 | ||
|   | 653b73c601 | ||
|   | f616e5a4e3 | ||
|   | c0317f60cc | ||
|   | 8abfe424e1 | ||
|   | 8de200de0b | ||
|   | f242e294be | ||
|   | 58cc7c8f84 | ||
|   | bd10f6ec08 | ||
|   | ed9cfb4c4b | ||
|   | a6b6e4c4b8 | ||
|   | 36ff5c0d45 | ||
|   | de6d34fec5 | ||
|   | 38f9067970 | ||
|   | 53a8a250d0 | ||
|   | 00f6d26ede | ||
|   | 6d09411c07 | ||
|   | 037e2bfd31 | ||
|   | c893552d4a | ||
|   | 4fd10162c9 | ||
|   | 392ee5ae7e | ||
|   | bf190609a0 | ||
|   | e982ac1e53 | ||
|   | b4747ea87b | ||
|   | df69bcecb7 | ||
|   | 7c93d91bae | ||
|   | 07da0cfb2b | ||
|   | b411a11c2c | ||
|   | 0555b84d05 | ||
|   | 790bddef63 | ||
|   | a3089b8aa7 | ||
|   | 77c8426d63 | ||
|   | faf226f6c2 | ||
|   | 06d143b81a | ||
|   | 08b6a0a702 | ||
|   | a20d1e3656 | ||
|   | 36cc3682ca | ||
|   | 1b495ecafa | ||
|   | 7d1a0be07e | ||
|   | 327f65c991 | ||
|   | 4ac89f6849 | ||
|   | db3b070ed0 | ||
|   | 6d940f476a | ||
|   | 1ca701dda4 | ||
|   | 291c44100c | ||
|   | c8d676e06b | ||
|   | 4c1ae0eddc | ||
|   | 39eadc814f | ||
|   | f7ecad61ba | ||
|   | fa4cb54549 | ||
|   | 2be33c5e0a | ||
|   | 904d7e5d5a | ||
|   | dbc4a65d48 | ||
|   | b93f4aabf1 | ||
|   | 9eaa40c7a4 | ||
|   | b308a882fb | ||
|   | 7f63ba2087 | ||
|   | d7269cfcc6 | ||
|   | 2850a574f6 | ||
|   | dcb8d4f702 | ||
|   | aeadc0c4b0 | ||
|   | 683c6b17be | ||
|   | 69dd5c91b7 | ||
|   | 5cf7dfca8f | ||
|   | 62a49d4244 | ||
|   | 93ee6322f2 | ||
|   | 914990b58a | ||
|   | f78bb5adb6 | ||
|   | 905f5e7289 | ||
|   | ec503618c3 | ||
|   | 7a41cbc314 | ||
|   | c58ba734e7 | ||
|   | 68f63be62f | ||
|   | 2aa4ca1351 | ||
|   | fbabb27787 | ||
|   | 0960d78eb5 | ||
|   | 474b40511f | ||
|   | 18b80aced3 | ||
|   | c75dca743a | ||
|   | 00d667ed51 | ||
|   | 51e098e807 | ||
|   | 5e2b27699e | ||
|   | be942c2888 | ||
|   | 584c1fbd97 | ||
|   | abc5c6e2b4 | ||
|   | d9de964035 | ||
|   | bb02158d1a | ||
|   | be10f097c7 | ||
|   | 7084bca783 | ||
|   | cd6f3a0fe5 | ||
|   | af2888331d | ||
|   | b92e5d7131 | ||
|   | f7265c85d0 | ||
|   | 8466dbf69f | ||
|   | 2dd0d69bcd | ||
|   | 6783c4ad83 | ||
|   | 07d7f4e18d | ||
|   | 54b1749986 | ||
|   | eaf264361f | ||
|   | d8f6f17a4f | ||
|   | 9a969cea63 | ||
|   | ef16327b2b | ||
|   | a6a6261168 | ||
|   | a01eb48db8 | ||
|   | eb103a8d9a | ||
|   | 2b5f989855 | ||
|   | 4e247a6ebe | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b964d362b7 | ||
|   | 3914e41f3c | ||
|   | 82bdfcb99b | ||
|   | 976cea600f | ||
|   | 8c8713c3f7 | ||
|   | 2359ae6ce7 | ||
|   | b570fd35c8 | ||
|   | 9d94e6b3b4 | ||
|   | cfab789823 | ||
|   | 81917425dc | ||
|   | bfb62709d4 | ||
|   | ca3f2ee782 | ||
|   | fc8703a40f | ||
|   | 80517c7ac1 | ||
|   | 2b4b46eaf8 | ||
|   | 40b9dae608 | ||
|   | 5975cd6e09 | ||
|   | 258c9ff52b | ||
|   | 89c5d498a4 | ||
|   | 76cb4d123a | ||
|   | f0c29c7699 | ||
|   | aa4151ced7 | ||
|   | 0a6fa978fa | ||
|   | dc02002b9d | ||
|   | f071a3f38b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b935231e47 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b9f7613567 | ||
|   | 1289a031ab | ||
|   | 289546ef6d | ||
|   | aacff4db5d | ||
|   | f833b56122 | ||
|   | 7eb0f2993f | ||
|   | abb341abfe | ||
|   | 0d90614369 | ||
|   | ec84bebeea | ||
|   | 9176867d6b | ||
|   | 281a137ff5 | ||
|   | d6543480ac | ||
|   | ae6391b866 | ||
|   | 10b56e4258 | ||
|   | 0ff2597957 | ||
|   | 026b28e962 | ||
|   | 9a1e67294a | ||
|   | cdb448a5cc | ||
|   | ab80e726e2 | ||
|   | 2d5d0f67b2 | ||
|   | d4100b6096 | ||
|   | 955e854d77 | ||
|   | 0c37f88c49 | ||
|   | 48167eeb9c | ||
|   | 24177197f7 | ||
|   | 863fc0ba97 | ||
|   | 9f7b229d02 | ||
|   | ffd909f3d9 | ||
|   | 1ebf096a33 | ||
|   | 96d51965e5 | ||
|   | 04b510b020 | ||
|   | c9a301d50e | ||
|   | b304bd1a8b | ||
|   | b99525b231 | ||
|   | 634db13990 | ||
|   | ad51a77989 | ||
|   | 3348a39e8a | ||
|   | 81c2e356ec | ||
|   | de6c3512d2 | ||
|   | 36dc1e938a | ||
|   | 07a78cf6f7 | ||
|   | eaa673e0c3 | ||
|   | f2c4ca081f | ||
|   | e3d707f0b4 | ||
|   | fb93fed2e5 | ||
|   | 95dfc2f23d | ||
|   | 77f897a768 | ||
|   | 4f0a6ef9a1 | ||
|   | 408df2093a | ||
|   | 66c6b0f5fc | ||
|   | dd01243391 | ||
|   | 66c17e250a | ||
|   | 723902e233 | ||
|   | 59fdb9f3b5 | ||
|   | d83502514a | ||
|   | 08e81b2ba6 | ||
|   | 1e808c965d | ||
|   | 563b58c9aa | ||
|   | cf223880e8 | ||
|   | 4058ca59ed | ||
|   | 1386c01733 | ||
|   | 46504947f7 | ||
|   | 0a44682014 | ||
|   | 06a57473a9 | ||
|   | fbed66ef1f | ||
|   | 99a0380ec5 | ||
|   | 68c51dc7aa | ||
|   | 3d945b0fc5 | ||
|   | 7b26a93d38 | ||
|   | 1b2eab00be | ||
|   | 750e849f09 | ||
|   | f32bf0cc3e | ||
|   | dbbe3145b6 | ||
|   | f8bf3ea2ef | ||
|   | 053bd31d43 | ||
|   | 1aefc3f37a | ||
|   | 3de955d9ce | ||
|   | 0ff88fd366 | ||
|   | eb84020773 | ||
|   | 4bbfea3c7c | ||
|   | 63d4fb7558 | ||
|   | 953895cd81 | ||
|   | a6c3f4efc0 | ||
|   | 11e880d034 | ||
|   | e4d6bdb398 | ||
|   | 6ced1783e3 | ||
|   | 8051f78d10 | ||
|   | b724176b23 | ||
|   | fdca16ea92 | ||
|   | f8fd8b432a | ||
|   | 9148ae70ce | ||
|   | 447cb26d28 | ||
|   | 2af36465f6 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d5f7265424 | ||
|   | cc16af7f2d | ||
|   | 7a4d75bc44 | ||
|   | ec0380fd3b | ||
|   | b17cc71dfb | ||
|   | 89b327ed7b | ||
|   | 9bf361a1b8 | ||
|   | d11c171c75 | ||
|   | c523c45d17 | ||
|   | c1b9c0e1b6 | ||
|   | 487b9ff03e | ||
|   | ec62b0cdfb | ||
|   | 6d0470064f | ||
|   | 7450b3fd1a | ||
|   | 5b70910d77 | ||
|   | 6aaddad56b | ||
|   | a5af974209 | ||
|   | 09e45f6f54 | ||
|   | d857d8850c | ||
|   | ccc50f2412 | ||
|   | 3905723900 | ||
|   | cee88473a2 | ||
|   | cdf613d3f8 | ||
|   | 52de5ff5ff | ||
|   | c4389a1679 | ||
|   | 35faaa6cae | ||
|   | 3c0b13975a | ||
|   | bc88696339 | ||
|   | 8f99c3f64a | ||
|   | 88016d96d4 | ||
|   | 47df73b18f | ||
|   | 1c12d2b8cd | ||
|   | eb38837a8c | ||
|   | 159c7fbfd1 | ||
|   | 7ee31f0884 | ||
|   | 0c5e12571a | ||
|   | 9db973217f | ||
|   | cf1a745283 | ||
|   | 834e3f1963 | ||
|   | 3f8f7573c9 | ||
|   | 156a0f1a3d | ||
|   | cc2a5b43dd | ||
|   | 0ae272f1f6 | ||
|   | 8774295e2e | ||
|   | 731064f7e9 | ||
|   | 2f75661c20 | ||
|   | be6f056f30 | ||
|   | 79599e1284 | ||
|   | a255585ab6 | ||
|   | e9bde225fe | ||
|   | d9521ac2a0 | ||
|   | d8b24ccccd | ||
|   | b4417a76d5 | ||
|   | 274f6eb54a | ||
|   | 21a5aaf35c | ||
|   | 05820a49d0 | ||
|   | 0c8d2594ef | ||
|   | 205bd2676b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 25849fd9cc | ||
|   | 7d6eac9ff7 | ||
|   | 31017ebc98 | ||
|   | 724a7b0ecc | ||
|   | 91e13d447a | ||
|   | 7c8ad9d535 | ||
|   | 9cd3ab853d | ||
|   | 0b0f8c5829 | ||
|   | ae7bc7fb1b | ||
|   | 09750872b5 | ||
|   | 076e51017b | ||
|   | 95e7b00996 | ||
|   | ddecf1ac21 | ||
|   | 17b12d29af | ||
|   | 9cc78680d6 | ||
|   | 14d42e43bf | ||
|   | ed5f5d4b33 | ||
|   | c3ba086fad | ||
|   | 7b5314605c | ||
|   | 3a806d6603 | ||
|   | 6dd33f900d | ||
|   | 2844bd474a | ||
|   | d865fcf999 | ||
|   | 79a2fc5a01 | ||
|   | 19d87abb8a | ||
|   | c4de46a85b | ||
|   | e79a434d9b | ||
|   | 9a801424c7 | ||
|   | 5cb186980a | ||
|   | 1629ade97f | ||
|   | ccf0011ac2 | ||
|   | 70077511a3 | ||
|   | dfbaf66021 | ||
|   | 62cea48a58 | ||
|   | c493c7dd67 | ||
|   | fdaceaddfd | ||
|   | a2f4073d54 | ||
|   | 2d01a99ec2 | ||
|   | 311d4c4262 | ||
|   | e14f5ba44d | ||
|   | 9babc85517 | ||
|   | 332a3fad3c | ||
|   | 8782aa4f60 | ||
|   | 475b84cc5f | ||
|   | 0f904d418b | ||
|   | 4ea4eec2d8 | ||
|   | afefa16615 | ||
|   | 1dccbee45c | ||
|   | 711a56db2f | ||
|   | 9d1c7dadff | ||
|   | 7d1953e387 | ||
|   | 023ecf2a64 | ||
|   | 934db458a3 | ||
|   | 0a6ae3b52a | ||
|   | bdd0b74d51 | ||
|   | 8837f2aca7 | ||
|   | 403cd2d8ef | ||
|   | ddfc528d63 | ||
|   | ddea2206c3 | ||
|   | 32aacac550 | 
| @@ -58,6 +58,7 @@ base_platforms: &base_platforms | |||||||
| # Extra components that trigger the full suite | # Extra components that trigger the full suite | ||||||
| components: &components | components: &components | ||||||
|   - homeassistant/components/alexa/** |   - homeassistant/components/alexa/** | ||||||
|  |   - homeassistant/components/analytics/** | ||||||
|   - homeassistant/components/application_credentials/** |   - homeassistant/components/application_credentials/** | ||||||
|   - homeassistant/components/assist_pipeline/** |   - homeassistant/components/assist_pipeline/** | ||||||
|   - homeassistant/components/auth/** |   - homeassistant/components/auth/** | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								.github/workflows/builder.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/builder.yml
									
									
									
									
										vendored
									
									
								
							| @@ -190,7 +190,7 @@ jobs: | |||||||
|           echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE |           echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE | ||||||
|  |  | ||||||
|       - name: Login to GitHub Container Registry |       - name: Login to GitHub Container Registry | ||||||
|         uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 |         uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 | ||||||
|         with: |         with: | ||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
|           username: ${{ github.repository_owner }} |           username: ${{ github.repository_owner }} | ||||||
| @@ -257,7 +257,7 @@ jobs: | |||||||
|           fi |           fi | ||||||
|  |  | ||||||
|       - name: Login to GitHub Container Registry |       - name: Login to GitHub Container Registry | ||||||
|         uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 |         uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 | ||||||
|         with: |         with: | ||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
|           username: ${{ github.repository_owner }} |           username: ${{ github.repository_owner }} | ||||||
| @@ -332,14 +332,14 @@ jobs: | |||||||
|  |  | ||||||
|       - name: Login to DockerHub |       - name: Login to DockerHub | ||||||
|         if: matrix.registry == 'docker.io/homeassistant' |         if: matrix.registry == 'docker.io/homeassistant' | ||||||
|         uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 |         uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 | ||||||
|         with: |         with: | ||||||
|           username: ${{ secrets.DOCKERHUB_USERNAME }} |           username: ${{ secrets.DOCKERHUB_USERNAME }} | ||||||
|           password: ${{ secrets.DOCKERHUB_TOKEN }} |           password: ${{ secrets.DOCKERHUB_TOKEN }} | ||||||
|  |  | ||||||
|       - name: Login to GitHub Container Registry |       - name: Login to GitHub Container Registry | ||||||
|         if: matrix.registry == 'ghcr.io/home-assistant' |         if: matrix.registry == 'ghcr.io/home-assistant' | ||||||
|         uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 |         uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 | ||||||
|         with: |         with: | ||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
|           username: ${{ github.repository_owner }} |           username: ${{ github.repository_owner }} | ||||||
| @@ -504,7 +504,7 @@ jobs: | |||||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||||
|  |  | ||||||
|       - name: Login to GitHub Container Registry |       - name: Login to GitHub Container Registry | ||||||
|         uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 |         uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 | ||||||
|         with: |         with: | ||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
|           username: ${{ github.repository_owner }} |           username: ${{ github.repository_owner }} | ||||||
|   | |||||||
							
								
								
									
										714
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										714
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										4
									
								
								.github/workflows/codeql.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/codeql.yml
									
									
									
									
										vendored
									
									
								
							| @@ -24,11 +24,11 @@ jobs: | |||||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||||
|  |  | ||||||
|       - name: Initialize CodeQL |       - name: Initialize CodeQL | ||||||
|         uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 |         uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 | ||||||
|         with: |         with: | ||||||
|           languages: python |           languages: python | ||||||
|  |  | ||||||
|       - name: Perform CodeQL Analysis |       - name: Perform CodeQL Analysis | ||||||
|         uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 |         uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 | ||||||
|         with: |         with: | ||||||
|           category: "/language:python" |           category: "/language:python" | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								.github/workflows/stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/stale.yml
									
									
									
									
										vendored
									
									
								
							| @@ -17,7 +17,7 @@ jobs: | |||||||
|       # - No PRs marked as no-stale |       # - No PRs marked as no-stale | ||||||
|       # - No issues (-1) |       # - No issues (-1) | ||||||
|       - name: 60 days stale PRs policy |       - name: 60 days stale PRs policy | ||||||
|         uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 |         uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 | ||||||
|         with: |         with: | ||||||
|           repo-token: ${{ secrets.GITHUB_TOKEN }} |           repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||||
|           days-before-stale: 60 |           days-before-stale: 60 | ||||||
| @@ -57,7 +57,7 @@ jobs: | |||||||
|       # - No issues marked as no-stale or help-wanted |       # - No issues marked as no-stale or help-wanted | ||||||
|       # - No PRs (-1) |       # - No PRs (-1) | ||||||
|       - name: 90 days stale issues |       - name: 90 days stale issues | ||||||
|         uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 |         uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 | ||||||
|         with: |         with: | ||||||
|           repo-token: ${{ steps.token.outputs.token }} |           repo-token: ${{ steps.token.outputs.token }} | ||||||
|           days-before-stale: 90 |           days-before-stale: 90 | ||||||
| @@ -87,7 +87,7 @@ jobs: | |||||||
|       # - No Issues marked as no-stale or help-wanted |       # - No Issues marked as no-stale or help-wanted | ||||||
|       # - No PRs (-1) |       # - No PRs (-1) | ||||||
|       - name: Needs more information stale issues policy |       - name: Needs more information stale issues policy | ||||||
|         uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 |         uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 | ||||||
|         with: |         with: | ||||||
|           repo-token: ${{ steps.token.outputs.token }} |           repo-token: ${{ steps.token.outputs.token }} | ||||||
|           only-labels: "needs-more-information" |           only-labels: "needs-more-information" | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								.github/workflows/wheels.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/wheels.yml
									
									
									
									
										vendored
									
									
								
							| @@ -160,7 +160,7 @@ jobs: | |||||||
|  |  | ||||||
|       # home-assistant/wheels doesn't support sha pinning |       # home-assistant/wheels doesn't support sha pinning | ||||||
|       - name: Build wheels |       - name: Build wheels | ||||||
|         uses: home-assistant/wheels@2025.07.0 |         uses: home-assistant/wheels@2025.09.1 | ||||||
|         with: |         with: | ||||||
|           abi: ${{ matrix.abi }} |           abi: ${{ matrix.abi }} | ||||||
|           tag: musllinux_1_2 |           tag: musllinux_1_2 | ||||||
| @@ -221,7 +221,7 @@ jobs: | |||||||
|  |  | ||||||
|       # home-assistant/wheels doesn't support sha pinning |       # home-assistant/wheels doesn't support sha pinning | ||||||
|       - name: Build wheels |       - name: Build wheels | ||||||
|         uses: home-assistant/wheels@2025.07.0 |         uses: home-assistant/wheels@2025.09.1 | ||||||
|         with: |         with: | ||||||
|           abi: ${{ matrix.abi }} |           abi: ${{ matrix.abi }} | ||||||
|           tag: musllinux_1_2 |           tag: musllinux_1_2 | ||||||
|   | |||||||
| @@ -203,6 +203,7 @@ homeassistant.components.feedreader.* | |||||||
| homeassistant.components.file_upload.* | homeassistant.components.file_upload.* | ||||||
| homeassistant.components.filesize.* | homeassistant.components.filesize.* | ||||||
| homeassistant.components.filter.* | homeassistant.components.filter.* | ||||||
|  | homeassistant.components.firefly_iii.* | ||||||
| homeassistant.components.fitbit.* | homeassistant.components.fitbit.* | ||||||
| homeassistant.components.flexit_bacnet.* | homeassistant.components.flexit_bacnet.* | ||||||
| homeassistant.components.flux_led.* | homeassistant.components.flux_led.* | ||||||
| @@ -325,6 +326,7 @@ homeassistant.components.london_underground.* | |||||||
| homeassistant.components.lookin.* | homeassistant.components.lookin.* | ||||||
| homeassistant.components.lovelace.* | homeassistant.components.lovelace.* | ||||||
| homeassistant.components.luftdaten.* | homeassistant.components.luftdaten.* | ||||||
|  | homeassistant.components.lunatone.* | ||||||
| homeassistant.components.madvr.* | homeassistant.components.madvr.* | ||||||
| homeassistant.components.manual.* | homeassistant.components.manual.* | ||||||
| homeassistant.components.mastodon.* | homeassistant.components.mastodon.* | ||||||
| @@ -443,6 +445,7 @@ homeassistant.components.rituals_perfume_genie.* | |||||||
| homeassistant.components.roborock.* | homeassistant.components.roborock.* | ||||||
| homeassistant.components.roku.* | homeassistant.components.roku.* | ||||||
| homeassistant.components.romy.* | homeassistant.components.romy.* | ||||||
|  | homeassistant.components.route_b_smart_meter.* | ||||||
| homeassistant.components.rpi_power.* | homeassistant.components.rpi_power.* | ||||||
| homeassistant.components.rss_feed_template.* | homeassistant.components.rss_feed_template.* | ||||||
| homeassistant.components.russound_rio.* | homeassistant.components.russound_rio.* | ||||||
| @@ -552,6 +555,7 @@ homeassistant.components.vacuum.* | |||||||
| homeassistant.components.vallox.* | homeassistant.components.vallox.* | ||||||
| homeassistant.components.valve.* | homeassistant.components.valve.* | ||||||
| homeassistant.components.velbus.* | homeassistant.components.velbus.* | ||||||
|  | homeassistant.components.vivotek.* | ||||||
| homeassistant.components.vlc_telnet.* | homeassistant.components.vlc_telnet.* | ||||||
| homeassistant.components.vodafone_station.* | homeassistant.components.vodafone_station.* | ||||||
| homeassistant.components.volvo.* | homeassistant.components.volvo.* | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								CODEOWNERS
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										18
									
								
								CODEOWNERS
									
									
									
										generated
									
									
									
								
							| @@ -316,6 +316,8 @@ build.json @home-assistant/supervisor | |||||||
| /tests/components/crownstone/ @Crownstone @RicArch97 | /tests/components/crownstone/ @Crownstone @RicArch97 | ||||||
| /homeassistant/components/cups/ @fabaff | /homeassistant/components/cups/ @fabaff | ||||||
| /tests/components/cups/ @fabaff | /tests/components/cups/ @fabaff | ||||||
|  | /homeassistant/components/cync/ @Kinachi249 | ||||||
|  | /tests/components/cync/ @Kinachi249 | ||||||
| /homeassistant/components/daikin/ @fredrike | /homeassistant/components/daikin/ @fredrike | ||||||
| /tests/components/daikin/ @fredrike | /tests/components/daikin/ @fredrike | ||||||
| /homeassistant/components/date/ @home-assistant/core | /homeassistant/components/date/ @home-assistant/core | ||||||
| @@ -410,6 +412,8 @@ build.json @home-assistant/supervisor | |||||||
| /homeassistant/components/egardia/ @jeroenterheerdt | /homeassistant/components/egardia/ @jeroenterheerdt | ||||||
| /homeassistant/components/eheimdigital/ @autinerd | /homeassistant/components/eheimdigital/ @autinerd | ||||||
| /tests/components/eheimdigital/ @autinerd | /tests/components/eheimdigital/ @autinerd | ||||||
|  | /homeassistant/components/ekeybionyx/ @richardpolzer | ||||||
|  | /tests/components/ekeybionyx/ @richardpolzer | ||||||
| /homeassistant/components/electrasmart/ @jafar-atili | /homeassistant/components/electrasmart/ @jafar-atili | ||||||
| /tests/components/electrasmart/ @jafar-atili | /tests/components/electrasmart/ @jafar-atili | ||||||
| /homeassistant/components/electric_kiwi/ @mikey0000 | /homeassistant/components/electric_kiwi/ @mikey0000 | ||||||
| @@ -488,6 +492,8 @@ build.json @home-assistant/supervisor | |||||||
| /tests/components/filesize/ @gjohansson-ST | /tests/components/filesize/ @gjohansson-ST | ||||||
| /homeassistant/components/filter/ @dgomes | /homeassistant/components/filter/ @dgomes | ||||||
| /tests/components/filter/ @dgomes | /tests/components/filter/ @dgomes | ||||||
|  | /homeassistant/components/firefly_iii/ @erwindouna | ||||||
|  | /tests/components/firefly_iii/ @erwindouna | ||||||
| /homeassistant/components/fireservicerota/ @cyberjunky | /homeassistant/components/fireservicerota/ @cyberjunky | ||||||
| /tests/components/fireservicerota/ @cyberjunky | /tests/components/fireservicerota/ @cyberjunky | ||||||
| /homeassistant/components/firmata/ @DaAwesomeP | /homeassistant/components/firmata/ @DaAwesomeP | ||||||
| @@ -904,6 +910,8 @@ build.json @home-assistant/supervisor | |||||||
| /homeassistant/components/luci/ @mzdrale | /homeassistant/components/luci/ @mzdrale | ||||||
| /homeassistant/components/luftdaten/ @fabaff @frenck | /homeassistant/components/luftdaten/ @fabaff @frenck | ||||||
| /tests/components/luftdaten/ @fabaff @frenck | /tests/components/luftdaten/ @fabaff @frenck | ||||||
|  | /homeassistant/components/lunatone/ @MoonDevLT | ||||||
|  | /tests/components/lunatone/ @MoonDevLT | ||||||
| /homeassistant/components/lupusec/ @majuss @suaveolent | /homeassistant/components/lupusec/ @majuss @suaveolent | ||||||
| /tests/components/lupusec/ @majuss @suaveolent | /tests/components/lupusec/ @majuss @suaveolent | ||||||
| /homeassistant/components/lutron/ @cdheiser @wilburCForce | /homeassistant/components/lutron/ @cdheiser @wilburCForce | ||||||
| @@ -949,6 +957,8 @@ build.json @home-assistant/supervisor | |||||||
| /tests/components/met_eireann/ @DylanGore | /tests/components/met_eireann/ @DylanGore | ||||||
| /homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame | /homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame | ||||||
| /tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame | /tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame | ||||||
|  | /homeassistant/components/meteo_lt/ @xE1H | ||||||
|  | /tests/components/meteo_lt/ @xE1H | ||||||
| /homeassistant/components/meteoalarm/ @rolfberkenbosch | /homeassistant/components/meteoalarm/ @rolfberkenbosch | ||||||
| /homeassistant/components/meteoclimatic/ @adrianmo | /homeassistant/components/meteoclimatic/ @adrianmo | ||||||
| /tests/components/meteoclimatic/ @adrianmo | /tests/components/meteoclimatic/ @adrianmo | ||||||
| @@ -972,8 +982,6 @@ build.json @home-assistant/supervisor | |||||||
| /tests/components/moat/ @bdraco | /tests/components/moat/ @bdraco | ||||||
| /homeassistant/components/mobile_app/ @home-assistant/core | /homeassistant/components/mobile_app/ @home-assistant/core | ||||||
| /tests/components/mobile_app/ @home-assistant/core | /tests/components/mobile_app/ @home-assistant/core | ||||||
| /homeassistant/components/modbus/ @janiversen |  | ||||||
| /tests/components/modbus/ @janiversen |  | ||||||
| /homeassistant/components/modem_callerid/ @tkdrob | /homeassistant/components/modem_callerid/ @tkdrob | ||||||
| /tests/components/modem_callerid/ @tkdrob | /tests/components/modem_callerid/ @tkdrob | ||||||
| /homeassistant/components/modern_forms/ @wonderslug | /homeassistant/components/modern_forms/ @wonderslug | ||||||
| @@ -1057,6 +1065,8 @@ build.json @home-assistant/supervisor | |||||||
| /homeassistant/components/nilu/ @hfurubotten | /homeassistant/components/nilu/ @hfurubotten | ||||||
| /homeassistant/components/nina/ @DeerMaximum | /homeassistant/components/nina/ @DeerMaximum | ||||||
| /tests/components/nina/ @DeerMaximum | /tests/components/nina/ @DeerMaximum | ||||||
|  | /homeassistant/components/nintendo_parental/ @pantherale0 | ||||||
|  | /tests/components/nintendo_parental/ @pantherale0 | ||||||
| /homeassistant/components/nissan_leaf/ @filcole | /homeassistant/components/nissan_leaf/ @filcole | ||||||
| /homeassistant/components/noaa_tides/ @jdelaney72 | /homeassistant/components/noaa_tides/ @jdelaney72 | ||||||
| /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe | /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe | ||||||
| @@ -1188,8 +1198,6 @@ build.json @home-assistant/supervisor | |||||||
| /tests/components/plex/ @jjlawren | /tests/components/plex/ @jjlawren | ||||||
| /homeassistant/components/plugwise/ @CoMPaTech @bouwew | /homeassistant/components/plugwise/ @CoMPaTech @bouwew | ||||||
| /tests/components/plugwise/ @CoMPaTech @bouwew | /tests/components/plugwise/ @CoMPaTech @bouwew | ||||||
| /homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa |  | ||||||
| /tests/components/plum_lightpad/ @ColinHarrington @prystupa |  | ||||||
| /homeassistant/components/point/ @fredrike | /homeassistant/components/point/ @fredrike | ||||||
| /tests/components/point/ @fredrike | /tests/components/point/ @fredrike | ||||||
| /homeassistant/components/pooldose/ @lmaertin | /homeassistant/components/pooldose/ @lmaertin | ||||||
| @@ -1332,6 +1340,8 @@ build.json @home-assistant/supervisor | |||||||
| /tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous | /tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous | ||||||
| /homeassistant/components/roon/ @pavoni | /homeassistant/components/roon/ @pavoni | ||||||
| /tests/components/roon/ @pavoni | /tests/components/roon/ @pavoni | ||||||
|  | /homeassistant/components/route_b_smart_meter/ @SeraphicRav | ||||||
|  | /tests/components/route_b_smart_meter/ @SeraphicRav | ||||||
| /homeassistant/components/rpi_power/ @shenxn @swetoast | /homeassistant/components/rpi_power/ @shenxn @swetoast | ||||||
| /tests/components/rpi_power/ @shenxn @swetoast | /tests/components/rpi_power/ @shenxn @swetoast | ||||||
| /homeassistant/components/rss_feed_template/ @home-assistant/core | /homeassistant/components/rss_feed_template/ @home-assistant/core | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								build.yaml
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								build.yaml
									
									
									
									
									
								
							| @@ -1,10 +1,10 @@ | |||||||
| image: ghcr.io/home-assistant/{arch}-homeassistant | image: ghcr.io/home-assistant/{arch}-homeassistant | ||||||
| build_from: | build_from: | ||||||
|   aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.1 |   aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.0 | ||||||
|   armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.1 |   armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.0 | ||||||
|   armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.1 |   armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.0 | ||||||
|   amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.1 |   amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.0 | ||||||
|   i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.1 |   i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.0 | ||||||
| codenotary: | codenotary: | ||||||
|   signer: notary@home-assistant.io |   signer: notary@home-assistant.io | ||||||
|   base_image: notary@home-assistant.io |   base_image: notary@home-assistant.io | ||||||
|   | |||||||
| @@ -616,34 +616,34 @@ async def async_enable_logging( | |||||||
|         ), |         ), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # Log errors to a file if we have write access to file or config dir |     logger = logging.getLogger() | ||||||
|  |     logger.setLevel(logging.INFO if verbose else logging.WARNING) | ||||||
|  |  | ||||||
|     if log_file is None: |     if log_file is None: | ||||||
|         err_log_path = hass.config.path(ERROR_LOG_FILENAME) |         default_log_path = hass.config.path(ERROR_LOG_FILENAME) | ||||||
|  |         if "SUPERVISOR" in os.environ: | ||||||
|  |             _LOGGER.info("Running in Supervisor, not logging to file") | ||||||
|  |             # Rename the default log file if it exists, since previous versions created | ||||||
|  |             # it even on Supervisor | ||||||
|  |             if os.path.isfile(default_log_path): | ||||||
|  |                 with contextlib.suppress(OSError): | ||||||
|  |                     os.rename(default_log_path, f"{default_log_path}.old") | ||||||
|  |             err_log_path = None | ||||||
|  |         else: | ||||||
|  |             err_log_path = default_log_path | ||||||
|     else: |     else: | ||||||
|         err_log_path = os.path.abspath(log_file) |         err_log_path = os.path.abspath(log_file) | ||||||
|  |  | ||||||
|     err_path_exists = os.path.isfile(err_log_path) |     if err_log_path: | ||||||
|     err_dir = os.path.dirname(err_log_path) |  | ||||||
|  |  | ||||||
|     # Check if we can write to the error log if it exists or that |  | ||||||
|     # we can create files in the containing directory if not. |  | ||||||
|     if (err_path_exists and os.access(err_log_path, os.W_OK)) or ( |  | ||||||
|         not err_path_exists and os.access(err_dir, os.W_OK) |  | ||||||
|     ): |  | ||||||
|         err_handler = await hass.async_add_executor_job( |         err_handler = await hass.async_add_executor_job( | ||||||
|             _create_log_file, err_log_path, log_rotate_days |             _create_log_file, err_log_path, log_rotate_days | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) |         err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) | ||||||
|  |  | ||||||
|         logger = logging.getLogger() |  | ||||||
|         logger.addHandler(err_handler) |         logger.addHandler(err_handler) | ||||||
|         logger.setLevel(logging.INFO if verbose else logging.WARNING) |  | ||||||
|  |  | ||||||
|         # Save the log file location for access by other components. |         # Save the log file location for access by other components. | ||||||
|         hass.data[DATA_LOGGING] = err_log_path |         hass.data[DATA_LOGGING] = err_log_path | ||||||
|     else: |  | ||||||
|         _LOGGER.error("Unable to set up error log %s (access denied)", err_log_path) |  | ||||||
|  |  | ||||||
|     async_activate_log_queue_handler(hass) |     async_activate_log_queue_handler(hass) | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								homeassistant/brands/eltako.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								homeassistant/brands/eltako.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | { | ||||||
|  |   "domain": "eltako", | ||||||
|  |   "name": "Eltako", | ||||||
|  |   "iot_standards": ["matter"] | ||||||
|  | } | ||||||
| @@ -1,5 +0,0 @@ | |||||||
| { |  | ||||||
|   "domain": "ibm", |  | ||||||
|   "name": "IBM", |  | ||||||
|   "integrations": ["watson_iot", "watson_tts"] |  | ||||||
| } |  | ||||||
							
								
								
									
										5
									
								
								homeassistant/brands/konnected.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								homeassistant/brands/konnected.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | { | ||||||
|  |   "domain": "konnected", | ||||||
|  |   "name": "Konnected", | ||||||
|  |   "integrations": ["konnected", "konnected_esphome"] | ||||||
|  | } | ||||||
							
								
								
									
										5
									
								
								homeassistant/brands/level.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								homeassistant/brands/level.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | { | ||||||
|  |   "domain": "level", | ||||||
|  |   "name": "Level", | ||||||
|  |   "iot_standards": ["matter"] | ||||||
|  | } | ||||||
| @@ -4,21 +4,21 @@ from __future__ import annotations | |||||||
|  |  | ||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
| import logging | import logging | ||||||
| from typing import cast |  | ||||||
|  |  | ||||||
| from aioacaia.acaiascale import AcaiaScale | from aioacaia.acaiascale import AcaiaScale | ||||||
| from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError | from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError | ||||||
| from bleak import BleakScanner |  | ||||||
|  |  | ||||||
| from homeassistant.components.bluetooth import async_get_scanner | from homeassistant.components.bluetooth import async_get_scanner | ||||||
| from homeassistant.config_entries import ConfigEntry | from homeassistant.config_entries import ConfigEntry | ||||||
| from homeassistant.const import CONF_ADDRESS | from homeassistant.const import CONF_ADDRESS | ||||||
| from homeassistant.core import HomeAssistant | from homeassistant.core import HomeAssistant | ||||||
|  | from homeassistant.helpers.debounce import Debouncer | ||||||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator | ||||||
|  |  | ||||||
| from .const import CONF_IS_NEW_STYLE_SCALE | from .const import CONF_IS_NEW_STYLE_SCALE | ||||||
|  |  | ||||||
| SCAN_INTERVAL = timedelta(seconds=15) | SCAN_INTERVAL = timedelta(seconds=15) | ||||||
|  | UPDATE_DEBOUNCE_TIME = 0.2 | ||||||
|  |  | ||||||
| _LOGGER = logging.getLogger(__name__) | _LOGGER = logging.getLogger(__name__) | ||||||
|  |  | ||||||
| @@ -40,12 +40,20 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]): | |||||||
|             config_entry=entry, |             config_entry=entry, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |         debouncer = Debouncer( | ||||||
|  |             hass=hass, | ||||||
|  |             logger=_LOGGER, | ||||||
|  |             cooldown=UPDATE_DEBOUNCE_TIME, | ||||||
|  |             immediate=True, | ||||||
|  |             function=self.async_update_listeners, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         self._scale = AcaiaScale( |         self._scale = AcaiaScale( | ||||||
|             address_or_ble_device=entry.data[CONF_ADDRESS], |             address_or_ble_device=entry.data[CONF_ADDRESS], | ||||||
|             name=entry.title, |             name=entry.title, | ||||||
|             is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE], |             is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE], | ||||||
|             notify_callback=self.async_update_listeners, |             notify_callback=debouncer.async_schedule_call, | ||||||
|             scanner=cast(BleakScanner, async_get_scanner(hass)), |             scanner=async_get_scanner(hass), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|   | |||||||
| @@ -7,5 +7,5 @@ | |||||||
|   "integration_type": "service", |   "integration_type": "service", | ||||||
|   "iot_class": "cloud_polling", |   "iot_class": "cloud_polling", | ||||||
|   "loggers": ["accuweather"], |   "loggers": ["accuweather"], | ||||||
|   "requirements": ["accuweather==4.2.1"] |   "requirements": ["accuweather==4.2.2"] | ||||||
| } | } | ||||||
|   | |||||||
| @@ -4,10 +4,18 @@ from __future__ import annotations | |||||||
|  |  | ||||||
| from airos.airos8 import AirOS8 | from airos.airos8 import AirOS8 | ||||||
|  |  | ||||||
| from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform | from homeassistant.const import ( | ||||||
|  |     CONF_HOST, | ||||||
|  |     CONF_PASSWORD, | ||||||
|  |     CONF_SSL, | ||||||
|  |     CONF_USERNAME, | ||||||
|  |     CONF_VERIFY_SSL, | ||||||
|  |     Platform, | ||||||
|  | ) | ||||||
| from homeassistant.core import HomeAssistant | from homeassistant.core import HomeAssistant | ||||||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||||||
|  |  | ||||||
|  | from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, SECTION_ADVANCED_SETTINGS | ||||||
| from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator | from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator | ||||||
|  |  | ||||||
| _PLATFORMS: list[Platform] = [ | _PLATFORMS: list[Platform] = [ | ||||||
| @@ -21,13 +29,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo | |||||||
|  |  | ||||||
|     # By default airOS 8 comes with self-signed SSL certificates, |     # By default airOS 8 comes with self-signed SSL certificates, | ||||||
|     # with no option in the web UI to change or upload a custom certificate. |     # with no option in the web UI to change or upload a custom certificate. | ||||||
|     session = async_get_clientsession(hass, verify_ssl=False) |     session = async_get_clientsession( | ||||||
|  |         hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     airos_device = AirOS8( |     airos_device = AirOS8( | ||||||
|         host=entry.data[CONF_HOST], |         host=entry.data[CONF_HOST], | ||||||
|         username=entry.data[CONF_USERNAME], |         username=entry.data[CONF_USERNAME], | ||||||
|         password=entry.data[CONF_PASSWORD], |         password=entry.data[CONF_PASSWORD], | ||||||
|         session=session, |         session=session, | ||||||
|  |         use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL], | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device) |     coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device) | ||||||
| @@ -40,6 +51,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo | |||||||
|     return True |     return True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: | ||||||
|  |     """Migrate old config entry.""" | ||||||
|  |  | ||||||
|  |     if entry.version > 1: | ||||||
|  |         # This means the user has downgraded from a future version | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     if entry.version == 1 and entry.minor_version == 1: | ||||||
|  |         new_data = {**entry.data} | ||||||
|  |         advanced_data = { | ||||||
|  |             CONF_SSL: DEFAULT_SSL, | ||||||
|  |             CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, | ||||||
|  |         } | ||||||
|  |         new_data[SECTION_ADVANCED_SETTINGS] = advanced_data | ||||||
|  |  | ||||||
|  |         hass.config_entries.async_update_entry( | ||||||
|  |             entry, | ||||||
|  |             data=new_data, | ||||||
|  |             minor_version=2, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     return True | ||||||
|  |  | ||||||
|  |  | ||||||
| async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: | async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: | ||||||
|     """Unload a config entry.""" |     """Unload a config entry.""" | ||||||
|     return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) |     return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ | |||||||
|  |  | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from collections.abc import Mapping | ||||||
| import logging | import logging | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| @@ -14,11 +15,23 @@ from airos.exceptions import ( | |||||||
| ) | ) | ||||||
| import voluptuous as vol | import voluptuous as vol | ||||||
|  |  | ||||||
| from homeassistant.config_entries import ConfigFlow, ConfigFlowResult | from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult | ||||||
| from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME | from homeassistant.const import ( | ||||||
|  |     CONF_HOST, | ||||||
|  |     CONF_PASSWORD, | ||||||
|  |     CONF_SSL, | ||||||
|  |     CONF_USERNAME, | ||||||
|  |     CONF_VERIFY_SSL, | ||||||
|  | ) | ||||||
|  | from homeassistant.data_entry_flow import section | ||||||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||||||
|  | from homeassistant.helpers.selector import ( | ||||||
|  |     TextSelector, | ||||||
|  |     TextSelectorConfig, | ||||||
|  |     TextSelectorType, | ||||||
|  | ) | ||||||
|  |  | ||||||
| from .const import DOMAIN | from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS | ||||||
| from .coordinator import AirOS8 | from .coordinator import AirOS8 | ||||||
|  |  | ||||||
| _LOGGER = logging.getLogger(__name__) | _LOGGER = logging.getLogger(__name__) | ||||||
| @@ -28,6 +41,15 @@ STEP_USER_DATA_SCHEMA = vol.Schema( | |||||||
|         vol.Required(CONF_HOST): str, |         vol.Required(CONF_HOST): str, | ||||||
|         vol.Required(CONF_USERNAME, default="ubnt"): str, |         vol.Required(CONF_USERNAME, default="ubnt"): str, | ||||||
|         vol.Required(CONF_PASSWORD): str, |         vol.Required(CONF_PASSWORD): str, | ||||||
|  |         vol.Required(SECTION_ADVANCED_SETTINGS): section( | ||||||
|  |             vol.Schema( | ||||||
|  |                 { | ||||||
|  |                     vol.Required(CONF_SSL, default=DEFAULT_SSL): bool, | ||||||
|  |                     vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |             {"collapsed": True}, | ||||||
|  |         ), | ||||||
|     } |     } | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -36,47 +58,109 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|     """Handle a config flow for Ubiquiti airOS.""" |     """Handle a config flow for Ubiquiti airOS.""" | ||||||
|  |  | ||||||
|     VERSION = 1 |     VERSION = 1 | ||||||
|  |     MINOR_VERSION = 2 | ||||||
|  |  | ||||||
|  |     def __init__(self) -> None: | ||||||
|  |         """Initialize the config flow.""" | ||||||
|  |         super().__init__() | ||||||
|  |         self.airos_device: AirOS8 | ||||||
|  |         self.errors: dict[str, str] = {} | ||||||
|  |  | ||||||
|     async def async_step_user( |     async def async_step_user( | ||||||
|         self, |         self, user_input: dict[str, Any] | None = None | ||||||
|         user_input: dict[str, Any] | None = None, |  | ||||||
|     ) -> ConfigFlowResult: |     ) -> ConfigFlowResult: | ||||||
|         """Handle the initial step.""" |         """Handle the manual input of host and credentials.""" | ||||||
|         errors: dict[str, str] = {} |         self.errors = {} | ||||||
|         if user_input is not None: |         if user_input is not None: | ||||||
|             # By default airOS 8 comes with self-signed SSL certificates, |             validated_info = await self._validate_and_get_device_info(user_input) | ||||||
|             # with no option in the web UI to change or upload a custom certificate. |             if validated_info: | ||||||
|             session = async_get_clientsession(self.hass, verify_ssl=False) |  | ||||||
|  |  | ||||||
|             airos_device = AirOS8( |  | ||||||
|                 host=user_input[CONF_HOST], |  | ||||||
|                 username=user_input[CONF_USERNAME], |  | ||||||
|                 password=user_input[CONF_PASSWORD], |  | ||||||
|                 session=session, |  | ||||||
|             ) |  | ||||||
|             try: |  | ||||||
|                 await airos_device.login() |  | ||||||
|                 airos_data = await airos_device.status() |  | ||||||
|  |  | ||||||
|             except ( |  | ||||||
|                 AirOSConnectionSetupError, |  | ||||||
|                 AirOSDeviceConnectionError, |  | ||||||
|             ): |  | ||||||
|                 errors["base"] = "cannot_connect" |  | ||||||
|             except (AirOSConnectionAuthenticationError, AirOSDataMissingError): |  | ||||||
|                 errors["base"] = "invalid_auth" |  | ||||||
|             except AirOSKeyDataMissingError: |  | ||||||
|                 errors["base"] = "key_data_missing" |  | ||||||
|             except Exception: |  | ||||||
|                 _LOGGER.exception("Unexpected exception") |  | ||||||
|                 errors["base"] = "unknown" |  | ||||||
|             else: |  | ||||||
|                 await self.async_set_unique_id(airos_data.derived.mac) |  | ||||||
|                 self._abort_if_unique_id_configured() |  | ||||||
|                 return self.async_create_entry( |                 return self.async_create_entry( | ||||||
|                     title=airos_data.host.hostname, data=user_input |                     title=validated_info["title"], | ||||||
|  |                     data=validated_info["data"], | ||||||
|  |                 ) | ||||||
|  |         return self.async_show_form( | ||||||
|  |             step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=self.errors | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     async def _validate_and_get_device_info( | ||||||
|  |         self, config_data: dict[str, Any] | ||||||
|  |     ) -> dict[str, Any] | None: | ||||||
|  |         """Validate user input with the device API.""" | ||||||
|  |         # By default airOS 8 comes with self-signed SSL certificates, | ||||||
|  |         # with no option in the web UI to change or upload a custom certificate. | ||||||
|  |         session = async_get_clientsession( | ||||||
|  |             self.hass, | ||||||
|  |             verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL], | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         airos_device = AirOS8( | ||||||
|  |             host=config_data[CONF_HOST], | ||||||
|  |             username=config_data[CONF_USERNAME], | ||||||
|  |             password=config_data[CONF_PASSWORD], | ||||||
|  |             session=session, | ||||||
|  |             use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL], | ||||||
|  |         ) | ||||||
|  |         try: | ||||||
|  |             await airos_device.login() | ||||||
|  |             airos_data = await airos_device.status() | ||||||
|  |  | ||||||
|  |         except ( | ||||||
|  |             AirOSConnectionSetupError, | ||||||
|  |             AirOSDeviceConnectionError, | ||||||
|  |         ): | ||||||
|  |             self.errors["base"] = "cannot_connect" | ||||||
|  |         except (AirOSConnectionAuthenticationError, AirOSDataMissingError): | ||||||
|  |             self.errors["base"] = "invalid_auth" | ||||||
|  |         except AirOSKeyDataMissingError: | ||||||
|  |             self.errors["base"] = "key_data_missing" | ||||||
|  |         except Exception: | ||||||
|  |             _LOGGER.exception("Unexpected exception during credential validation") | ||||||
|  |             self.errors["base"] = "unknown" | ||||||
|  |         else: | ||||||
|  |             await self.async_set_unique_id(airos_data.derived.mac) | ||||||
|  |  | ||||||
|  |             if self.source == SOURCE_REAUTH: | ||||||
|  |                 self._abort_if_unique_id_mismatch() | ||||||
|  |             else: | ||||||
|  |                 self._abort_if_unique_id_configured() | ||||||
|  |  | ||||||
|  |             return {"title": airos_data.host.hostname, "data": config_data} | ||||||
|  |  | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     async def async_step_reauth( | ||||||
|  |         self, | ||||||
|  |         user_input: Mapping[str, Any], | ||||||
|  |     ) -> ConfigFlowResult: | ||||||
|  |         """Perform reauthentication upon an API authentication error.""" | ||||||
|  |         return await self.async_step_reauth_confirm(user_input) | ||||||
|  |  | ||||||
|  |     async def async_step_reauth_confirm( | ||||||
|  |         self, | ||||||
|  |         user_input: Mapping[str, Any], | ||||||
|  |     ) -> ConfigFlowResult: | ||||||
|  |         """Perform reauthentication upon an API authentication error.""" | ||||||
|  |         self.errors = {} | ||||||
|  |  | ||||||
|  |         if user_input: | ||||||
|  |             validate_data = {**self._get_reauth_entry().data, **user_input} | ||||||
|  |             if await self._validate_and_get_device_info(config_data=validate_data): | ||||||
|  |                 return self.async_update_reload_and_abort( | ||||||
|  |                     self._get_reauth_entry(), | ||||||
|  |                     data_updates=validate_data, | ||||||
|                 ) |                 ) | ||||||
|  |  | ||||||
|         return self.async_show_form( |         return self.async_show_form( | ||||||
|             step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors |             step_id="reauth_confirm", | ||||||
|  |             data_schema=vol.Schema( | ||||||
|  |                 { | ||||||
|  |                     vol.Required(CONF_PASSWORD): TextSelector( | ||||||
|  |                         TextSelectorConfig( | ||||||
|  |                             type=TextSelectorType.PASSWORD, | ||||||
|  |                             autocomplete="current-password", | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |             errors=self.errors, | ||||||
|         ) |         ) | ||||||
|   | |||||||
| @@ -7,3 +7,8 @@ DOMAIN = "airos" | |||||||
| SCAN_INTERVAL = timedelta(minutes=1) | SCAN_INTERVAL = timedelta(minutes=1) | ||||||
|  |  | ||||||
| MANUFACTURER = "Ubiquiti" | MANUFACTURER = "Ubiquiti" | ||||||
|  |  | ||||||
|  | DEFAULT_VERIFY_SSL = False | ||||||
|  | DEFAULT_SSL = True | ||||||
|  |  | ||||||
|  | SECTION_ADVANCED_SETTINGS = "advanced_settings" | ||||||
|   | |||||||
| @@ -14,7 +14,7 @@ from airos.exceptions import ( | |||||||
|  |  | ||||||
| from homeassistant.config_entries import ConfigEntry | from homeassistant.config_entries import ConfigEntry | ||||||
| from homeassistant.core import HomeAssistant | from homeassistant.core import HomeAssistant | ||||||
| from homeassistant.exceptions import ConfigEntryError | from homeassistant.exceptions import ConfigEntryAuthFailed | ||||||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||||||
|  |  | ||||||
| from .const import DOMAIN, SCAN_INTERVAL | from .const import DOMAIN, SCAN_INTERVAL | ||||||
| @@ -47,9 +47,9 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]): | |||||||
|         try: |         try: | ||||||
|             await self.airos_device.login() |             await self.airos_device.login() | ||||||
|             return await self.airos_device.status() |             return await self.airos_device.status() | ||||||
|         except (AirOSConnectionAuthenticationError,) as err: |         except AirOSConnectionAuthenticationError as err: | ||||||
|             _LOGGER.exception("Error authenticating with airOS device") |             _LOGGER.exception("Error authenticating with airOS device") | ||||||
|             raise ConfigEntryError( |             raise ConfigEntryAuthFailed( | ||||||
|                 translation_domain=DOMAIN, translation_key="invalid_auth" |                 translation_domain=DOMAIN, translation_key="invalid_auth" | ||||||
|             ) from err |             ) from err | ||||||
|         except ( |         except ( | ||||||
|   | |||||||
| @@ -2,11 +2,11 @@ | |||||||
|  |  | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
| from homeassistant.const import CONF_HOST | from homeassistant.const import CONF_HOST, CONF_SSL | ||||||
| from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo | from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo | ||||||
| from homeassistant.helpers.update_coordinator import CoordinatorEntity | from homeassistant.helpers.update_coordinator import CoordinatorEntity | ||||||
|  |  | ||||||
| from .const import DOMAIN, MANUFACTURER | from .const import DOMAIN, MANUFACTURER, SECTION_ADVANCED_SETTINGS | ||||||
| from .coordinator import AirOSDataUpdateCoordinator | from .coordinator import AirOSDataUpdateCoordinator | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -20,9 +20,14 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]): | |||||||
|         super().__init__(coordinator) |         super().__init__(coordinator) | ||||||
|  |  | ||||||
|         airos_data = self.coordinator.data |         airos_data = self.coordinator.data | ||||||
|  |         url_schema = ( | ||||||
|  |             "https" | ||||||
|  |             if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] | ||||||
|  |             else "http" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         configuration_url: str | None = ( |         configuration_url: str | None = ( | ||||||
|             f"https://{coordinator.config_entry.data[CONF_HOST]}" |             f"{url_schema}://{coordinator.config_entry.data[CONF_HOST]}" | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         self._attr_device_info = DeviceInfo( |         self._attr_device_info = DeviceInfo( | ||||||
|   | |||||||
| @@ -6,5 +6,5 @@ | |||||||
|   "documentation": "https://www.home-assistant.io/integrations/airos", |   "documentation": "https://www.home-assistant.io/integrations/airos", | ||||||
|   "iot_class": "local_polling", |   "iot_class": "local_polling", | ||||||
|   "quality_scale": "bronze", |   "quality_scale": "bronze", | ||||||
|   "requirements": ["airos==0.5.1"] |   "requirements": ["airos==0.5.5"] | ||||||
| } | } | ||||||
|   | |||||||
| @@ -2,6 +2,14 @@ | |||||||
|   "config": { |   "config": { | ||||||
|     "flow_title": "Ubiquiti airOS device", |     "flow_title": "Ubiquiti airOS device", | ||||||
|     "step": { |     "step": { | ||||||
|  |       "reauth_confirm": { | ||||||
|  |         "data": { | ||||||
|  |           "password": "[%key:common::config_flow::data::password%]" | ||||||
|  |         }, | ||||||
|  |         "data_description": { | ||||||
|  |           "password": "[%key:component::airos::config::step::user::data_description::password%]" | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|       "user": { |       "user": { | ||||||
|         "data": { |         "data": { | ||||||
|           "host": "[%key:common::config_flow::data::host%]", |           "host": "[%key:common::config_flow::data::host%]", | ||||||
| @@ -12,6 +20,18 @@ | |||||||
|           "host": "IP address or hostname of the airOS device", |           "host": "IP address or hostname of the airOS device", | ||||||
|           "username": "Administrator username for the airOS device, normally 'ubnt'", |           "username": "Administrator username for the airOS device, normally 'ubnt'", | ||||||
|           "password": "Password configured through the UISP app or web interface" |           "password": "Password configured through the UISP app or web interface" | ||||||
|  |         }, | ||||||
|  |         "sections": { | ||||||
|  |           "advanced_settings": { | ||||||
|  |             "data": { | ||||||
|  |               "ssl": "Use HTTPS", | ||||||
|  |               "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" | ||||||
|  |             }, | ||||||
|  |             "data_description": { | ||||||
|  |               "ssl": "Whether the connection should be encrypted (required for most devices)", | ||||||
|  |               "verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates" | ||||||
|  |             } | ||||||
|  |           } | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
| @@ -22,7 +42,9 @@ | |||||||
|       "unknown": "[%key:common::config_flow::error::unknown%]" |       "unknown": "[%key:common::config_flow::error::unknown%]" | ||||||
|     }, |     }, | ||||||
|     "abort": { |     "abort": { | ||||||
|       "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" |       "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", | ||||||
|  |       "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", | ||||||
|  |       "unique_id_mismatch": "Re-authentication should be used for the same device not a new one" | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   "entity": { |   "entity": { | ||||||
|   | |||||||
| @@ -23,6 +23,10 @@ STEP_USER_DATA_SCHEMA = vol.Schema( | |||||||
|     } |     } | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | URL_API_INTEGRATION = { | ||||||
|  |     "url": "https://dashboard.airthings.com/integrations/api-integration" | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
| class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): | class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): | ||||||
|     """Handle a config flow for Airthings.""" |     """Handle a config flow for Airthings.""" | ||||||
| @@ -37,11 +41,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|             return self.async_show_form( |             return self.async_show_form( | ||||||
|                 step_id="user", |                 step_id="user", | ||||||
|                 data_schema=STEP_USER_DATA_SCHEMA, |                 data_schema=STEP_USER_DATA_SCHEMA, | ||||||
|                 description_placeholders={ |                 description_placeholders=URL_API_INTEGRATION, | ||||||
|                     "url": ( |  | ||||||
|                         "https://dashboard.airthings.com/integrations/api-integration" |  | ||||||
|                     ), |  | ||||||
|                 }, |  | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|         errors = {} |         errors = {} | ||||||
| @@ -65,5 +65,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|             return self.async_create_entry(title="Airthings", data=user_input) |             return self.async_create_entry(title="Airthings", data=user_input) | ||||||
|  |  | ||||||
|         return self.async_show_form( |         return self.async_show_form( | ||||||
|             step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors |             step_id="user", | ||||||
|  |             data_schema=STEP_USER_DATA_SCHEMA, | ||||||
|  |             errors=errors, | ||||||
|  |             description_placeholders=URL_API_INTEGRATION, | ||||||
|         ) |         ) | ||||||
|   | |||||||
| @@ -4,9 +4,9 @@ | |||||||
|       "user": { |       "user": { | ||||||
|         "data": { |         "data": { | ||||||
|           "id": "ID", |           "id": "ID", | ||||||
|           "secret": "Secret", |           "secret": "Secret" | ||||||
|           "description": "Login at {url} to find your credentials" |         }, | ||||||
|         } |         "description": "Log in at {url} to find your credentials" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     "error": { |     "error": { | ||||||
|   | |||||||
| @@ -6,8 +6,13 @@ import dataclasses | |||||||
| import logging | import logging | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice | from airthings_ble import ( | ||||||
|  |     AirthingsBluetoothDeviceData, | ||||||
|  |     AirthingsDevice, | ||||||
|  |     UnsupportedDeviceError, | ||||||
|  | ) | ||||||
| from bleak import BleakError | from bleak import BleakError | ||||||
|  | from habluetooth import BluetoothServiceInfoBleak | ||||||
| import voluptuous as vol | import voluptuous as vol | ||||||
|  |  | ||||||
| from homeassistant.components import bluetooth | from homeassistant.components import bluetooth | ||||||
| @@ -27,6 +32,7 @@ SERVICE_UUIDS = [ | |||||||
|     "b42e4a8e-ade7-11e4-89d3-123b93f75cba", |     "b42e4a8e-ade7-11e4-89d3-123b93f75cba", | ||||||
|     "b42e1c08-ade7-11e4-89d3-123b93f75cba", |     "b42e1c08-ade7-11e4-89d3-123b93f75cba", | ||||||
|     "b42e3882-ade7-11e4-89d3-123b93f75cba", |     "b42e3882-ade7-11e4-89d3-123b93f75cba", | ||||||
|  |     "b42e90a2-ade7-11e4-89d3-123b93f75cba", | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -37,6 +43,7 @@ class Discovery: | |||||||
|     name: str |     name: str | ||||||
|     discovery_info: BluetoothServiceInfo |     discovery_info: BluetoothServiceInfo | ||||||
|     device: AirthingsDevice |     device: AirthingsDevice | ||||||
|  |     data: AirthingsBluetoothDeviceData | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_name(device: AirthingsDevice) -> str: | def get_name(device: AirthingsDevice) -> str: | ||||||
| @@ -44,7 +51,7 @@ def get_name(device: AirthingsDevice) -> str: | |||||||
|  |  | ||||||
|     name = device.friendly_name() |     name = device.friendly_name() | ||||||
|     if identifier := device.identifier: |     if identifier := device.identifier: | ||||||
|         name += f" ({identifier})" |         name += f" ({device.model.value}{identifier})" | ||||||
|     return name |     return name | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -62,8 +69,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|         self._discovered_device: Discovery | None = None |         self._discovered_device: Discovery | None = None | ||||||
|         self._discovered_devices: dict[str, Discovery] = {} |         self._discovered_devices: dict[str, Discovery] = {} | ||||||
|  |  | ||||||
|     async def _get_device_data( |     async def _get_device( | ||||||
|         self, discovery_info: BluetoothServiceInfo |         self, data: AirthingsBluetoothDeviceData, discovery_info: BluetoothServiceInfo | ||||||
|     ) -> AirthingsDevice: |     ) -> AirthingsDevice: | ||||||
|         ble_device = bluetooth.async_ble_device_from_address( |         ble_device = bluetooth.async_ble_device_from_address( | ||||||
|             self.hass, discovery_info.address |             self.hass, discovery_info.address | ||||||
| @@ -72,10 +79,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|             _LOGGER.debug("no ble_device in _get_device_data") |             _LOGGER.debug("no ble_device in _get_device_data") | ||||||
|             raise AirthingsDeviceUpdateError("No ble_device") |             raise AirthingsDeviceUpdateError("No ble_device") | ||||||
|  |  | ||||||
|         airthings = AirthingsBluetoothDeviceData(_LOGGER) |  | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             data = await airthings.update_device(ble_device) |             device = await data.update_device(ble_device) | ||||||
|         except BleakError as err: |         except BleakError as err: | ||||||
|             _LOGGER.error( |             _LOGGER.error( | ||||||
|                 "Error connecting to and getting data from %s: %s", |                 "Error connecting to and getting data from %s: %s", | ||||||
| @@ -83,12 +88,15 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|                 err, |                 err, | ||||||
|             ) |             ) | ||||||
|             raise AirthingsDeviceUpdateError("Failed getting device data") from err |             raise AirthingsDeviceUpdateError("Failed getting device data") from err | ||||||
|  |         except UnsupportedDeviceError: | ||||||
|  |             _LOGGER.debug("Skipping unsupported device: %s", discovery_info.name) | ||||||
|  |             raise | ||||||
|         except Exception as err: |         except Exception as err: | ||||||
|             _LOGGER.error( |             _LOGGER.error( | ||||||
|                 "Unknown error occurred from %s: %s", discovery_info.address, err |                 "Unknown error occurred from %s: %s", discovery_info.address, err | ||||||
|             ) |             ) | ||||||
|             raise |             raise | ||||||
|         return data |         return device | ||||||
|  |  | ||||||
|     async def async_step_bluetooth( |     async def async_step_bluetooth( | ||||||
|         self, discovery_info: BluetoothServiceInfo |         self, discovery_info: BluetoothServiceInfo | ||||||
| @@ -98,17 +106,21 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|         await self.async_set_unique_id(discovery_info.address) |         await self.async_set_unique_id(discovery_info.address) | ||||||
|         self._abort_if_unique_id_configured() |         self._abort_if_unique_id_configured() | ||||||
|  |  | ||||||
|  |         data = AirthingsBluetoothDeviceData(logger=_LOGGER) | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             device = await self._get_device_data(discovery_info) |             device = await self._get_device(data=data, discovery_info=discovery_info) | ||||||
|         except AirthingsDeviceUpdateError: |         except AirthingsDeviceUpdateError: | ||||||
|             return self.async_abort(reason="cannot_connect") |             return self.async_abort(reason="cannot_connect") | ||||||
|  |         except UnsupportedDeviceError: | ||||||
|  |             return self.async_abort(reason="unsupported_device") | ||||||
|         except Exception: |         except Exception: | ||||||
|             _LOGGER.exception("Unknown error occurred") |             _LOGGER.exception("Unknown error occurred") | ||||||
|             return self.async_abort(reason="unknown") |             return self.async_abort(reason="unknown") | ||||||
|  |  | ||||||
|         name = get_name(device) |         name = get_name(device) | ||||||
|         self.context["title_placeholders"] = {"name": name} |         self.context["title_placeholders"] = {"name": name} | ||||||
|         self._discovered_device = Discovery(name, discovery_info, device) |         self._discovered_device = Discovery(name, discovery_info, device, data=data) | ||||||
|  |  | ||||||
|         return await self.async_step_bluetooth_confirm() |         return await self.async_step_bluetooth_confirm() | ||||||
|  |  | ||||||
| @@ -117,6 +129,12 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|     ) -> ConfigFlowResult: |     ) -> ConfigFlowResult: | ||||||
|         """Confirm discovery.""" |         """Confirm discovery.""" | ||||||
|         if user_input is not None: |         if user_input is not None: | ||||||
|  |             if ( | ||||||
|  |                 self._discovered_device is not None | ||||||
|  |                 and self._discovered_device.device.firmware.need_firmware_upgrade | ||||||
|  |             ): | ||||||
|  |                 return self.async_abort(reason="firmware_upgrade_required") | ||||||
|  |  | ||||||
|             return self.async_create_entry( |             return self.async_create_entry( | ||||||
|                 title=self.context["title_placeholders"]["name"], data={} |                 title=self.context["title_placeholders"]["name"], data={} | ||||||
|             ) |             ) | ||||||
| @@ -137,6 +155,9 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|             self._abort_if_unique_id_configured() |             self._abort_if_unique_id_configured() | ||||||
|             discovery = self._discovered_devices[address] |             discovery = self._discovered_devices[address] | ||||||
|  |  | ||||||
|  |             if discovery.device.firmware.need_firmware_upgrade: | ||||||
|  |                 return self.async_abort(reason="firmware_upgrade_required") | ||||||
|  |  | ||||||
|             self.context["title_placeholders"] = { |             self.context["title_placeholders"] = { | ||||||
|                 "name": discovery.name, |                 "name": discovery.name, | ||||||
|             } |             } | ||||||
| @@ -146,32 +167,53 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|             return self.async_create_entry(title=discovery.name, data={}) |             return self.async_create_entry(title=discovery.name, data={}) | ||||||
|  |  | ||||||
|         current_addresses = self._async_current_ids(include_ignore=False) |         current_addresses = self._async_current_ids(include_ignore=False) | ||||||
|  |         devices: list[BluetoothServiceInfoBleak] = [] | ||||||
|         for discovery_info in async_discovered_service_info(self.hass): |         for discovery_info in async_discovered_service_info(self.hass): | ||||||
|             address = discovery_info.address |             address = discovery_info.address | ||||||
|             if address in current_addresses or address in self._discovered_devices: |             if address in current_addresses or address in self._discovered_devices: | ||||||
|                 continue |                 continue | ||||||
|  |  | ||||||
|             if MFCT_ID not in discovery_info.manufacturer_data: |             if MFCT_ID not in discovery_info.manufacturer_data: | ||||||
|                 continue |                 continue | ||||||
|  |  | ||||||
|             if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids): |             if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids): | ||||||
|  |                 _LOGGER.debug( | ||||||
|  |                     "Skipping unsupported device: %s (%s)", discovery_info.name, address | ||||||
|  |                 ) | ||||||
|                 continue |                 continue | ||||||
|  |             devices.append(discovery_info) | ||||||
|  |  | ||||||
|  |         for discovery_info in devices: | ||||||
|  |             address = discovery_info.address | ||||||
|  |             data = AirthingsBluetoothDeviceData(logger=_LOGGER) | ||||||
|             try: |             try: | ||||||
|                 device = await self._get_device_data(discovery_info) |                 device = await self._get_device(data, discovery_info) | ||||||
|             except AirthingsDeviceUpdateError: |             except AirthingsDeviceUpdateError: | ||||||
|                 return self.async_abort(reason="cannot_connect") |                 _LOGGER.error( | ||||||
|  |                     "Error connecting to and getting data from %s (%s)", | ||||||
|  |                     discovery_info.name, | ||||||
|  |                     discovery_info.address, | ||||||
|  |                 ) | ||||||
|  |                 continue | ||||||
|  |             except UnsupportedDeviceError: | ||||||
|  |                 _LOGGER.debug( | ||||||
|  |                     "Skipping unsupported device: %s (%s)", | ||||||
|  |                     discovery_info.name, | ||||||
|  |                     discovery_info.address, | ||||||
|  |                 ) | ||||||
|  |                 continue | ||||||
|             except Exception: |             except Exception: | ||||||
|                 _LOGGER.exception("Unknown error occurred") |                 _LOGGER.exception("Unknown error occurred") | ||||||
|                 return self.async_abort(reason="unknown") |                 return self.async_abort(reason="unknown") | ||||||
|             name = get_name(device) |             name = get_name(device) | ||||||
|             self._discovered_devices[address] = Discovery(name, discovery_info, device) |             _LOGGER.debug("Discovered Airthings device: %s (%s)", name, address) | ||||||
|  |             self._discovered_devices[address] = Discovery( | ||||||
|  |                 name, discovery_info, device, data | ||||||
|  |             ) | ||||||
|  |  | ||||||
|         if not self._discovered_devices: |         if not self._discovered_devices: | ||||||
|             return self.async_abort(reason="no_devices_found") |             return self.async_abort(reason="no_devices_found") | ||||||
|  |  | ||||||
|         titles = { |         titles = { | ||||||
|             address: discovery.device.name |             address: get_name(discovery.device) | ||||||
|             for (address, discovery) in self._discovered_devices.items() |             for (address, discovery) in self._discovered_devices.items() | ||||||
|         } |         } | ||||||
|         return self.async_show_form( |         return self.async_show_form( | ||||||
|   | |||||||
| @@ -17,6 +17,10 @@ | |||||||
|     { |     { | ||||||
|       "manufacturer_id": 820, |       "manufacturer_id": 820, | ||||||
|       "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba" |       "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "manufacturer_id": 820, | ||||||
|  |       "service_uuid": "b42e90a2-ade7-11e4-89d3-123b93f75cba" | ||||||
|     } |     } | ||||||
|   ], |   ], | ||||||
|   "codeowners": ["@vincegio", "@LaStrada"], |   "codeowners": ["@vincegio", "@LaStrada"], | ||||||
| @@ -24,5 +28,5 @@ | |||||||
|   "dependencies": ["bluetooth_adapters"], |   "dependencies": ["bluetooth_adapters"], | ||||||
|   "documentation": "https://www.home-assistant.io/integrations/airthings_ble", |   "documentation": "https://www.home-assistant.io/integrations/airthings_ble", | ||||||
|   "iot_class": "local_polling", |   "iot_class": "local_polling", | ||||||
|   "requirements": ["airthings-ble==0.9.2"] |   "requirements": ["airthings-ble==1.1.1"] | ||||||
| } | } | ||||||
|   | |||||||
| @@ -114,6 +114,8 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { | |||||||
|     ), |     ), | ||||||
| } | } | ||||||
|  |  | ||||||
|  | PARALLEL_UPDATES = 0 | ||||||
|  |  | ||||||
|  |  | ||||||
| @callback | @callback | ||||||
| def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: | def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: | ||||||
|   | |||||||
| @@ -6,6 +6,9 @@ | |||||||
|         "description": "[%key:component::bluetooth::config::step::user::description%]", |         "description": "[%key:component::bluetooth::config::step::user::description%]", | ||||||
|         "data": { |         "data": { | ||||||
|           "address": "[%key:common::config_flow::data::device%]" |           "address": "[%key:common::config_flow::data::device%]" | ||||||
|  |         }, | ||||||
|  |         "data_description": { | ||||||
|  |           "address": "The Airthings devices discovered via Bluetooth." | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|       "bluetooth_confirm": { |       "bluetooth_confirm": { | ||||||
| @@ -17,6 +20,8 @@ | |||||||
|       "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", |       "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", | ||||||
|       "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", |       "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", | ||||||
|       "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", |       "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", | ||||||
|  |       "firmware_upgrade_required": "Your device requires a firmware upgrade. Please use the Airthings app (Android/iOS) to upgrade it.", | ||||||
|  |       "unsupported_device": "Unsupported device", | ||||||
|       "unknown": "[%key:common::config_flow::error::unknown%]" |       "unknown": "[%key:common::config_flow::error::unknown%]" | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   | |||||||
| @@ -2,17 +2,14 @@ | |||||||
|  |  | ||||||
| from airtouch4pyapi import AirTouch | from airtouch4pyapi import AirTouch | ||||||
|  |  | ||||||
| from homeassistant.config_entries import ConfigEntry |  | ||||||
| from homeassistant.const import CONF_HOST, Platform | from homeassistant.const import CONF_HOST, Platform | ||||||
| from homeassistant.core import HomeAssistant | from homeassistant.core import HomeAssistant | ||||||
| from homeassistant.exceptions import ConfigEntryNotReady | from homeassistant.exceptions import ConfigEntryNotReady | ||||||
|  |  | ||||||
| from .coordinator import AirtouchDataUpdateCoordinator | from .coordinator import AirTouch4ConfigEntry, AirtouchDataUpdateCoordinator | ||||||
|  |  | ||||||
| PLATFORMS = [Platform.CLIMATE] | PLATFORMS = [Platform.CLIMATE] | ||||||
|  |  | ||||||
| type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool: | async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool: | ||||||
|     """Set up AirTouch4 from a config entry.""" |     """Set up AirTouch4 from a config entry.""" | ||||||
| @@ -22,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> | |||||||
|     info = airtouch.GetAcs() |     info = airtouch.GetAcs() | ||||||
|     if not info: |     if not info: | ||||||
|         raise ConfigEntryNotReady |         raise ConfigEntryNotReady | ||||||
|     coordinator = AirtouchDataUpdateCoordinator(hass, airtouch) |     coordinator = AirtouchDataUpdateCoordinator(hass, entry, airtouch) | ||||||
|     await coordinator.async_config_entry_first_refresh() |     await coordinator.async_config_entry_first_refresh() | ||||||
|     entry.runtime_data = coordinator |     entry.runtime_data = coordinator | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,26 +2,34 @@ | |||||||
|  |  | ||||||
| import logging | import logging | ||||||
|  |  | ||||||
|  | from airtouch4pyapi import AirTouch | ||||||
| from airtouch4pyapi.airtouch import AirTouchStatus | from airtouch4pyapi.airtouch import AirTouchStatus | ||||||
|  |  | ||||||
| from homeassistant.components.climate import SCAN_INTERVAL | from homeassistant.components.climate import SCAN_INTERVAL | ||||||
|  | from homeassistant.config_entries import ConfigEntry | ||||||
|  | from homeassistant.core import HomeAssistant | ||||||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||||||
|  |  | ||||||
| from .const import DOMAIN | from .const import DOMAIN | ||||||
|  |  | ||||||
| _LOGGER = logging.getLogger(__name__) | _LOGGER = logging.getLogger(__name__) | ||||||
|  |  | ||||||
|  | type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator] | ||||||
|  |  | ||||||
|  |  | ||||||
| class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): | class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): | ||||||
|     """Class to manage fetching Airtouch data.""" |     """Class to manage fetching Airtouch data.""" | ||||||
|  |  | ||||||
|     def __init__(self, hass, airtouch): |     def __init__( | ||||||
|  |         self, hass: HomeAssistant, entry: AirTouch4ConfigEntry, airtouch: AirTouch | ||||||
|  |     ) -> None: | ||||||
|         """Initialize global Airtouch data updater.""" |         """Initialize global Airtouch data updater.""" | ||||||
|         self.airtouch = airtouch |         self.airtouch = airtouch | ||||||
|  |  | ||||||
|         super().__init__( |         super().__init__( | ||||||
|             hass, |             hass, | ||||||
|             _LOGGER, |             _LOGGER, | ||||||
|  |             config_entry=entry, | ||||||
|             name=DOMAIN, |             name=DOMAIN, | ||||||
|             update_interval=SCAN_INTERVAL, |             update_interval=SCAN_INTERVAL, | ||||||
|         ) |         ) | ||||||
|   | |||||||
| @@ -6,17 +6,19 @@ from collections.abc import Callable | |||||||
| from dataclasses import dataclass | from dataclasses import dataclass | ||||||
| from typing import Any, Final | from typing import Any, Final | ||||||
|  |  | ||||||
| from aioairzone.common import GrilleAngle, OperationMode, SleepTimeout | from aioairzone.common import GrilleAngle, OperationMode, QAdapt, SleepTimeout | ||||||
| from aioairzone.const import ( | from aioairzone.const import ( | ||||||
|     API_COLD_ANGLE, |     API_COLD_ANGLE, | ||||||
|     API_HEAT_ANGLE, |     API_HEAT_ANGLE, | ||||||
|     API_MODE, |     API_MODE, | ||||||
|  |     API_Q_ADAPT, | ||||||
|     API_SLEEP, |     API_SLEEP, | ||||||
|     AZD_COLD_ANGLE, |     AZD_COLD_ANGLE, | ||||||
|     AZD_HEAT_ANGLE, |     AZD_HEAT_ANGLE, | ||||||
|     AZD_MASTER, |     AZD_MASTER, | ||||||
|     AZD_MODE, |     AZD_MODE, | ||||||
|     AZD_MODES, |     AZD_MODES, | ||||||
|  |     AZD_Q_ADAPT, | ||||||
|     AZD_SLEEP, |     AZD_SLEEP, | ||||||
|     AZD_ZONES, |     AZD_ZONES, | ||||||
| ) | ) | ||||||
| @@ -65,6 +67,14 @@ SLEEP_DICT: Final[dict[str, int]] = { | |||||||
|     "90m": SleepTimeout.SLEEP_90, |     "90m": SleepTimeout.SLEEP_90, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | Q_ADAPT_DICT: Final[dict[str, int]] = { | ||||||
|  |     "standard": QAdapt.STANDARD, | ||||||
|  |     "power": QAdapt.POWER, | ||||||
|  |     "silence": QAdapt.SILENCE, | ||||||
|  |     "minimum": QAdapt.MINIMUM, | ||||||
|  |     "maximum": QAdapt.MAXIMUM, | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
| def main_zone_options( | def main_zone_options( | ||||||
|     zone_data: dict[str, Any], |     zone_data: dict[str, Any], | ||||||
| @@ -83,6 +93,14 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( | |||||||
|         options_fn=main_zone_options, |         options_fn=main_zone_options, | ||||||
|         translation_key="modes", |         translation_key="modes", | ||||||
|     ), |     ), | ||||||
|  |     AirzoneSelectDescription( | ||||||
|  |         api_param=API_Q_ADAPT, | ||||||
|  |         entity_category=EntityCategory.CONFIG, | ||||||
|  |         key=AZD_Q_ADAPT, | ||||||
|  |         options=list(Q_ADAPT_DICT), | ||||||
|  |         options_dict=Q_ADAPT_DICT, | ||||||
|  |         translation_key="q_adapt", | ||||||
|  |     ), | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -63,6 +63,16 @@ | |||||||
|           "stop": "Stop" |           "stop": "Stop" | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|  |       "q_adapt": { | ||||||
|  |         "name": "Q-Adapt", | ||||||
|  |         "state": { | ||||||
|  |           "standard": "Standard", | ||||||
|  |           "power": "Power", | ||||||
|  |           "silence": "Silence", | ||||||
|  |           "minimum": "Minimum", | ||||||
|  |           "maximum": "Maximum" | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|       "sleep_times": { |       "sleep_times": { | ||||||
|         "name": "Sleep", |         "name": "Sleep", | ||||||
|         "state": { |         "state": { | ||||||
|   | |||||||
| @@ -22,6 +22,17 @@ class OAuth2FlowHandler( | |||||||
|     VERSION = CONFIG_FLOW_VERSION |     VERSION = CONFIG_FLOW_VERSION | ||||||
|     MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION |     MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION | ||||||
|  |  | ||||||
|  |     async def async_step_user( | ||||||
|  |         self, user_input: dict[str, Any] | None = None | ||||||
|  |     ) -> ConfigFlowResult: | ||||||
|  |         """Check we have the cloud integration set up.""" | ||||||
|  |         if "cloud" not in self.hass.config.components: | ||||||
|  |             return self.async_abort( | ||||||
|  |                 reason="cloud_not_enabled", | ||||||
|  |                 description_placeholders={"default_config": "default_config"}, | ||||||
|  |             ) | ||||||
|  |         return await super().async_step_user(user_input) | ||||||
|  |  | ||||||
|     async def async_step_reauth( |     async def async_step_reauth( | ||||||
|         self, user_input: Mapping[str, Any] |         self, user_input: Mapping[str, Any] | ||||||
|     ) -> ConfigFlowResult: |     ) -> ConfigFlowResult: | ||||||
|   | |||||||
| @@ -24,7 +24,8 @@ | |||||||
|       "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", |       "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", | ||||||
|       "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", |       "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", | ||||||
|       "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", |       "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", | ||||||
|       "wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account." |       "wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account.", | ||||||
|  |       "cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml." | ||||||
|     }, |     }, | ||||||
|     "create_entry": { |     "create_entry": { | ||||||
|       "default": "[%key:common::config_flow::create_entry::authenticated%]" |       "default": "[%key:common::config_flow::create_entry::authenticated%]" | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ from aioamazondevices.api import AmazonDevice | |||||||
| from aioamazondevices.const import SENSOR_STATE_OFF | from aioamazondevices.const import SENSOR_STATE_OFF | ||||||
|  |  | ||||||
| from homeassistant.components.binary_sensor import ( | from homeassistant.components.binary_sensor import ( | ||||||
|  |     DOMAIN as BINARY_SENSOR_DOMAIN, | ||||||
|     BinarySensorDeviceClass, |     BinarySensorDeviceClass, | ||||||
|     BinarySensorEntity, |     BinarySensorEntity, | ||||||
|     BinarySensorEntityDescription, |     BinarySensorEntityDescription, | ||||||
| @@ -17,9 +18,12 @@ from homeassistant.components.binary_sensor import ( | |||||||
| from homeassistant.const import EntityCategory | from homeassistant.const import EntityCategory | ||||||
| from homeassistant.core import HomeAssistant | from homeassistant.core import HomeAssistant | ||||||
| from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback | ||||||
|  | import homeassistant.helpers.entity_registry as er | ||||||
|  |  | ||||||
|  | from .const import _LOGGER, DOMAIN | ||||||
| from .coordinator import AmazonConfigEntry | from .coordinator import AmazonConfigEntry | ||||||
| from .entity import AmazonEntity | from .entity import AmazonEntity | ||||||
|  | from .utils import async_update_unique_id | ||||||
|  |  | ||||||
| # Coordinator is used to centralize the data updates | # Coordinator is used to centralize the data updates | ||||||
| PARALLEL_UPDATES = 0 | PARALLEL_UPDATES = 0 | ||||||
| @@ -31,6 +35,7 @@ class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription): | |||||||
|  |  | ||||||
|     is_on_fn: Callable[[AmazonDevice, str], bool] |     is_on_fn: Callable[[AmazonDevice, str], bool] | ||||||
|     is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True |     is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True | ||||||
|  |     is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: True | ||||||
|  |  | ||||||
|  |  | ||||||
| BINARY_SENSORS: Final = ( | BINARY_SENSORS: Final = ( | ||||||
| @@ -40,47 +45,52 @@ BINARY_SENSORS: Final = ( | |||||||
|         entity_category=EntityCategory.DIAGNOSTIC, |         entity_category=EntityCategory.DIAGNOSTIC, | ||||||
|         is_on_fn=lambda device, _: device.online, |         is_on_fn=lambda device, _: device.online, | ||||||
|     ), |     ), | ||||||
|  |     AmazonBinarySensorEntityDescription( | ||||||
|  |         key="detectionState", | ||||||
|  |         device_class=BinarySensorDeviceClass.MOTION, | ||||||
|  |         is_on_fn=lambda device, key: bool( | ||||||
|  |             device.sensors[key].value != SENSOR_STATE_OFF | ||||||
|  |         ), | ||||||
|  |         is_supported=lambda device, key: device.sensors.get(key) is not None, | ||||||
|  |         is_available_fn=lambda device, key: ( | ||||||
|  |             device.online | ||||||
|  |             and (sensor := device.sensors.get(key)) is not None | ||||||
|  |             and sensor.error is False | ||||||
|  |         ), | ||||||
|  |     ), | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | DEPRECATED_BINARY_SENSORS: Final = ( | ||||||
|     AmazonBinarySensorEntityDescription( |     AmazonBinarySensorEntityDescription( | ||||||
|         key="bluetooth", |         key="bluetooth", | ||||||
|         entity_category=EntityCategory.DIAGNOSTIC, |         entity_category=EntityCategory.DIAGNOSTIC, | ||||||
|         translation_key="bluetooth", |         translation_key="bluetooth", | ||||||
|         is_on_fn=lambda device, _: device.bluetooth_state, |         is_on_fn=lambda device, key: False, | ||||||
|     ), |     ), | ||||||
|     AmazonBinarySensorEntityDescription( |     AmazonBinarySensorEntityDescription( | ||||||
|         key="babyCryDetectionState", |         key="babyCryDetectionState", | ||||||
|         translation_key="baby_cry_detection", |         translation_key="baby_cry_detection", | ||||||
|         is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), |         is_on_fn=lambda device, key: False, | ||||||
|         is_supported=lambda device, key: device.sensors.get(key) is not None, |  | ||||||
|     ), |     ), | ||||||
|     AmazonBinarySensorEntityDescription( |     AmazonBinarySensorEntityDescription( | ||||||
|         key="beepingApplianceDetectionState", |         key="beepingApplianceDetectionState", | ||||||
|         translation_key="beeping_appliance_detection", |         translation_key="beeping_appliance_detection", | ||||||
|         is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), |         is_on_fn=lambda device, key: False, | ||||||
|         is_supported=lambda device, key: device.sensors.get(key) is not None, |  | ||||||
|     ), |     ), | ||||||
|     AmazonBinarySensorEntityDescription( |     AmazonBinarySensorEntityDescription( | ||||||
|         key="coughDetectionState", |         key="coughDetectionState", | ||||||
|         translation_key="cough_detection", |         translation_key="cough_detection", | ||||||
|         is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), |         is_on_fn=lambda device, key: False, | ||||||
|         is_supported=lambda device, key: device.sensors.get(key) is not None, |  | ||||||
|     ), |     ), | ||||||
|     AmazonBinarySensorEntityDescription( |     AmazonBinarySensorEntityDescription( | ||||||
|         key="dogBarkDetectionState", |         key="dogBarkDetectionState", | ||||||
|         translation_key="dog_bark_detection", |         translation_key="dog_bark_detection", | ||||||
|         is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), |         is_on_fn=lambda device, key: False, | ||||||
|         is_supported=lambda device, key: device.sensors.get(key) is not None, |  | ||||||
|     ), |  | ||||||
|     AmazonBinarySensorEntityDescription( |  | ||||||
|         key="humanPresenceDetectionState", |  | ||||||
|         device_class=BinarySensorDeviceClass.MOTION, |  | ||||||
|         is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), |  | ||||||
|         is_supported=lambda device, key: device.sensors.get(key) is not None, |  | ||||||
|     ), |     ), | ||||||
|     AmazonBinarySensorEntityDescription( |     AmazonBinarySensorEntityDescription( | ||||||
|         key="waterSoundsDetectionState", |         key="waterSoundsDetectionState", | ||||||
|         translation_key="water_sounds_detection", |         translation_key="water_sounds_detection", | ||||||
|         is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), |         is_on_fn=lambda device, key: False, | ||||||
|         is_supported=lambda device, key: device.sensors.get(key) is not None, |  | ||||||
|     ), |     ), | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -94,13 +104,46 @@ async def async_setup_entry( | |||||||
|  |  | ||||||
|     coordinator = entry.runtime_data |     coordinator = entry.runtime_data | ||||||
|  |  | ||||||
|     async_add_entities( |     entity_registry = er.async_get(hass) | ||||||
|         AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) |  | ||||||
|         for sensor_desc in BINARY_SENSORS |     # Replace unique id for "detectionState" binary sensor | ||||||
|         for serial_num in coordinator.data |     await async_update_unique_id( | ||||||
|         if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key) |         hass, | ||||||
|  |         coordinator, | ||||||
|  |         BINARY_SENSOR_DOMAIN, | ||||||
|  |         "humanPresenceDetectionState", | ||||||
|  |         "detectionState", | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     # Clean up deprecated sensors | ||||||
|  |     for sensor_desc in DEPRECATED_BINARY_SENSORS: | ||||||
|  |         for serial_num in coordinator.data: | ||||||
|  |             unique_id = f"{serial_num}-{sensor_desc.key}" | ||||||
|  |             if entity_id := entity_registry.async_get_entity_id( | ||||||
|  |                 BINARY_SENSOR_DOMAIN, DOMAIN, unique_id | ||||||
|  |             ): | ||||||
|  |                 _LOGGER.debug("Removing deprecated entity %s", entity_id) | ||||||
|  |                 entity_registry.async_remove(entity_id) | ||||||
|  |  | ||||||
|  |     known_devices: set[str] = set() | ||||||
|  |  | ||||||
|  |     def _check_device() -> None: | ||||||
|  |         current_devices = set(coordinator.data) | ||||||
|  |         new_devices = current_devices - known_devices | ||||||
|  |         if new_devices: | ||||||
|  |             known_devices.update(new_devices) | ||||||
|  |             async_add_entities( | ||||||
|  |                 AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) | ||||||
|  |                 for sensor_desc in BINARY_SENSORS | ||||||
|  |                 for serial_num in new_devices | ||||||
|  |                 if sensor_desc.is_supported( | ||||||
|  |                     coordinator.data[serial_num], sensor_desc.key | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |     _check_device() | ||||||
|  |     entry.async_on_unload(coordinator.async_add_listener(_check_device)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): | class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): | ||||||
|     """Binary sensor device.""" |     """Binary sensor device.""" | ||||||
| @@ -113,3 +156,13 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): | |||||||
|         return self.entity_description.is_on_fn( |         return self.entity_description.is_on_fn( | ||||||
|             self.device, self.entity_description.key |             self.device, self.entity_description.key | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def available(self) -> bool: | ||||||
|  |         """Return if entity is available.""" | ||||||
|  |         return ( | ||||||
|  |             self.entity_description.is_available_fn( | ||||||
|  |                 self.device, self.entity_description.key | ||||||
|  |             ) | ||||||
|  |             and super().available | ||||||
|  |         ) | ||||||
|   | |||||||
| @@ -64,7 +64,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|                 data = await validate_input(self.hass, user_input) |                 data = await validate_input(self.hass, user_input) | ||||||
|             except CannotConnect: |             except CannotConnect: | ||||||
|                 errors["base"] = "cannot_connect" |                 errors["base"] = "cannot_connect" | ||||||
|             except (CannotAuthenticate, TypeError): |             except CannotAuthenticate: | ||||||
|                 errors["base"] = "invalid_auth" |                 errors["base"] = "invalid_auth" | ||||||
|             except CannotRetrieveData: |             except CannotRetrieveData: | ||||||
|                 errors["base"] = "cannot_retrieve_data" |                 errors["base"] = "cannot_retrieve_data" | ||||||
| @@ -112,7 +112,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|                 ) |                 ) | ||||||
|             except CannotConnect: |             except CannotConnect: | ||||||
|                 errors["base"] = "cannot_connect" |                 errors["base"] = "cannot_connect" | ||||||
|             except (CannotAuthenticate, TypeError): |             except CannotAuthenticate: | ||||||
|                 errors["base"] = "invalid_auth" |                 errors["base"] = "invalid_auth" | ||||||
|             except CannotRetrieveData: |             except CannotRetrieveData: | ||||||
|                 errors["base"] = "cannot_retrieve_data" |                 errors["base"] = "cannot_retrieve_data" | ||||||
|   | |||||||
| @@ -68,7 +68,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): | |||||||
|                 translation_key="cannot_retrieve_data_with_error", |                 translation_key="cannot_retrieve_data_with_error", | ||||||
|                 translation_placeholders={"error": repr(err)}, |                 translation_placeholders={"error": repr(err)}, | ||||||
|             ) from err |             ) from err | ||||||
|         except (CannotAuthenticate, TypeError) as err: |         except CannotAuthenticate as err: | ||||||
|             raise ConfigEntryAuthFailed( |             raise ConfigEntryAuthFailed( | ||||||
|                 translation_domain=DOMAIN, |                 translation_domain=DOMAIN, | ||||||
|                 translation_key="invalid_auth", |                 translation_key="invalid_auth", | ||||||
|   | |||||||
| @@ -60,7 +60,5 @@ def build_device_data(device: AmazonDevice) -> dict[str, Any]: | |||||||
|         "online": device.online, |         "online": device.online, | ||||||
|         "serial number": device.serial_number, |         "serial number": device.serial_number, | ||||||
|         "software version": device.software_version, |         "software version": device.software_version, | ||||||
|         "do not disturb": device.do_not_disturb, |         "sensors": device.sensors, | ||||||
|         "response style": device.response_style, |  | ||||||
|         "bluetooth state": device.bluetooth_state, |  | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,44 +1,4 @@ | |||||||
| { | { | ||||||
|   "entity": { |  | ||||||
|     "binary_sensor": { |  | ||||||
|       "bluetooth": { |  | ||||||
|         "default": "mdi:bluetooth-off", |  | ||||||
|         "state": { |  | ||||||
|           "on": "mdi:bluetooth" |  | ||||||
|         } |  | ||||||
|       }, |  | ||||||
|       "baby_cry_detection": { |  | ||||||
|         "default": "mdi:account-voice-off", |  | ||||||
|         "state": { |  | ||||||
|           "on": "mdi:account-voice" |  | ||||||
|         } |  | ||||||
|       }, |  | ||||||
|       "beeping_appliance_detection": { |  | ||||||
|         "default": "mdi:bell-off", |  | ||||||
|         "state": { |  | ||||||
|           "on": "mdi:bell-ring" |  | ||||||
|         } |  | ||||||
|       }, |  | ||||||
|       "cough_detection": { |  | ||||||
|         "default": "mdi:blur-off", |  | ||||||
|         "state": { |  | ||||||
|           "on": "mdi:blur" |  | ||||||
|         } |  | ||||||
|       }, |  | ||||||
|       "dog_bark_detection": { |  | ||||||
|         "default": "mdi:dog-side-off", |  | ||||||
|         "state": { |  | ||||||
|           "on": "mdi:dog-side" |  | ||||||
|         } |  | ||||||
|       }, |  | ||||||
|       "water_sounds_detection": { |  | ||||||
|         "default": "mdi:water-pump-off", |  | ||||||
|         "state": { |  | ||||||
|           "on": "mdi:water-pump" |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   "services": { |   "services": { | ||||||
|     "send_sound": { |     "send_sound": { | ||||||
|       "service": "mdi:cast-audio" |       "service": "mdi:cast-audio" | ||||||
|   | |||||||
| @@ -7,6 +7,6 @@ | |||||||
|   "integration_type": "hub", |   "integration_type": "hub", | ||||||
|   "iot_class": "cloud_polling", |   "iot_class": "cloud_polling", | ||||||
|   "loggers": ["aioamazondevices"], |   "loggers": ["aioamazondevices"], | ||||||
|   "quality_scale": "silver", |   "quality_scale": "platinum", | ||||||
|   "requirements": ["aioamazondevices==6.0.0"] |   "requirements": ["aioamazondevices==6.2.9"] | ||||||
| } | } | ||||||
|   | |||||||
| @@ -57,13 +57,23 @@ async def async_setup_entry( | |||||||
|  |  | ||||||
|     coordinator = entry.runtime_data |     coordinator = entry.runtime_data | ||||||
|  |  | ||||||
|     async_add_entities( |     known_devices: set[str] = set() | ||||||
|         AmazonNotifyEntity(coordinator, serial_num, sensor_desc) |  | ||||||
|         for sensor_desc in NOTIFY |     def _check_device() -> None: | ||||||
|         for serial_num in coordinator.data |         current_devices = set(coordinator.data) | ||||||
|         if sensor_desc.subkey in coordinator.data[serial_num].capabilities |         new_devices = current_devices - known_devices | ||||||
|         and sensor_desc.is_supported(coordinator.data[serial_num]) |         if new_devices: | ||||||
|     ) |             known_devices.update(new_devices) | ||||||
|  |             async_add_entities( | ||||||
|  |                 AmazonNotifyEntity(coordinator, serial_num, sensor_desc) | ||||||
|  |                 for sensor_desc in NOTIFY | ||||||
|  |                 for serial_num in new_devices | ||||||
|  |                 if sensor_desc.subkey in coordinator.data[serial_num].capabilities | ||||||
|  |                 and sensor_desc.is_supported(coordinator.data[serial_num]) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |     _check_device() | ||||||
|  |     entry.async_on_unload(coordinator.async_add_listener(_check_device)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AmazonNotifyEntity(AmazonEntity, NotifyEntity): | class AmazonNotifyEntity(AmazonEntity, NotifyEntity): | ||||||
|   | |||||||
| @@ -53,7 +53,7 @@ rules: | |||||||
|   docs-supported-functions: done |   docs-supported-functions: done | ||||||
|   docs-troubleshooting: done |   docs-troubleshooting: done | ||||||
|   docs-use-cases: done |   docs-use-cases: done | ||||||
|   dynamic-devices: todo |   dynamic-devices: done | ||||||
|   entity-category: done |   entity-category: done | ||||||
|   entity-device-class: done |   entity-device-class: done | ||||||
|   entity-disabled-by-default: done |   entity-disabled-by-default: done | ||||||
|   | |||||||
| @@ -31,15 +31,20 @@ class AmazonSensorEntityDescription(SensorEntityDescription): | |||||||
|     """Amazon Devices sensor entity description.""" |     """Amazon Devices sensor entity description.""" | ||||||
|  |  | ||||||
|     native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None |     native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None | ||||||
|  |     is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: ( | ||||||
|  |         device.online | ||||||
|  |         and (sensor := device.sensors.get(key)) is not None | ||||||
|  |         and sensor.error is False | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| SENSORS: Final = ( | SENSORS: Final = ( | ||||||
|     AmazonSensorEntityDescription( |     AmazonSensorEntityDescription( | ||||||
|         key="temperature", |         key="temperature", | ||||||
|         device_class=SensorDeviceClass.TEMPERATURE, |         device_class=SensorDeviceClass.TEMPERATURE, | ||||||
|         native_unit_of_measurement_fn=lambda device, _key: ( |         native_unit_of_measurement_fn=lambda device, key: ( | ||||||
|             UnitOfTemperature.CELSIUS |             UnitOfTemperature.CELSIUS | ||||||
|             if device.sensors[_key].scale == "CELSIUS" |             if key in device.sensors and device.sensors[key].scale == "CELSIUS" | ||||||
|             else UnitOfTemperature.FAHRENHEIT |             else UnitOfTemperature.FAHRENHEIT | ||||||
|         ), |         ), | ||||||
|         state_class=SensorStateClass.MEASUREMENT, |         state_class=SensorStateClass.MEASUREMENT, | ||||||
| @@ -62,12 +67,22 @@ async def async_setup_entry( | |||||||
|  |  | ||||||
|     coordinator = entry.runtime_data |     coordinator = entry.runtime_data | ||||||
|  |  | ||||||
|     async_add_entities( |     known_devices: set[str] = set() | ||||||
|         AmazonSensorEntity(coordinator, serial_num, sensor_desc) |  | ||||||
|         for sensor_desc in SENSORS |     def _check_device() -> None: | ||||||
|         for serial_num in coordinator.data |         current_devices = set(coordinator.data) | ||||||
|         if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None |         new_devices = current_devices - known_devices | ||||||
|     ) |         if new_devices: | ||||||
|  |             known_devices.update(new_devices) | ||||||
|  |             async_add_entities( | ||||||
|  |                 AmazonSensorEntity(coordinator, serial_num, sensor_desc) | ||||||
|  |                 for sensor_desc in SENSORS | ||||||
|  |                 for serial_num in new_devices | ||||||
|  |                 if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |     _check_device() | ||||||
|  |     entry.async_on_unload(coordinator.async_add_listener(_check_device)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AmazonSensorEntity(AmazonEntity, SensorEntity): | class AmazonSensorEntity(AmazonEntity, SensorEntity): | ||||||
| @@ -89,3 +104,13 @@ class AmazonSensorEntity(AmazonEntity, SensorEntity): | |||||||
|     def native_value(self) -> StateType: |     def native_value(self) -> StateType: | ||||||
|         """Return the state of the sensor.""" |         """Return the state of the sensor.""" | ||||||
|         return self.device.sensors[self.entity_description.key].value |         return self.device.sensors[self.entity_description.key].value | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def available(self) -> bool: | ||||||
|  |         """Return if entity is available.""" | ||||||
|  |         return ( | ||||||
|  |             self.entity_description.is_available_fn( | ||||||
|  |                 self.device, self.entity_description.key | ||||||
|  |             ) | ||||||
|  |             and super().available | ||||||
|  |         ) | ||||||
|   | |||||||
| @@ -58,26 +58,6 @@ | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   "entity": { |   "entity": { | ||||||
|     "binary_sensor": { |  | ||||||
|       "bluetooth": { |  | ||||||
|         "name": "Bluetooth" |  | ||||||
|       }, |  | ||||||
|       "baby_cry_detection": { |  | ||||||
|         "name": "Baby crying" |  | ||||||
|       }, |  | ||||||
|       "beeping_appliance_detection": { |  | ||||||
|         "name": "Beeping appliance" |  | ||||||
|       }, |  | ||||||
|       "cough_detection": { |  | ||||||
|         "name": "Coughing" |  | ||||||
|       }, |  | ||||||
|       "dog_bark_detection": { |  | ||||||
|         "name": "Dog barking" |  | ||||||
|       }, |  | ||||||
|       "water_sounds_detection": { |  | ||||||
|         "name": "Water sounds" |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|     "notify": { |     "notify": { | ||||||
|       "speak": { |       "speak": { | ||||||
|         "name": "Speak" |         "name": "Speak" | ||||||
|   | |||||||
| @@ -8,13 +8,21 @@ from typing import TYPE_CHECKING, Any, Final | |||||||
|  |  | ||||||
| from aioamazondevices.api import AmazonDevice | from aioamazondevices.api import AmazonDevice | ||||||
|  |  | ||||||
| from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription | from homeassistant.components.switch import ( | ||||||
|  |     DOMAIN as SWITCH_DOMAIN, | ||||||
|  |     SwitchEntity, | ||||||
|  |     SwitchEntityDescription, | ||||||
|  | ) | ||||||
| from homeassistant.core import HomeAssistant | from homeassistant.core import HomeAssistant | ||||||
| from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback | ||||||
|  |  | ||||||
| from .coordinator import AmazonConfigEntry | from .coordinator import AmazonConfigEntry | ||||||
| from .entity import AmazonEntity | from .entity import AmazonEntity | ||||||
| from .utils import alexa_api_call | from .utils import ( | ||||||
|  |     alexa_api_call, | ||||||
|  |     async_remove_dnd_from_virtual_group, | ||||||
|  |     async_update_unique_id, | ||||||
|  | ) | ||||||
|  |  | ||||||
| PARALLEL_UPDATES = 1 | PARALLEL_UPDATES = 1 | ||||||
|  |  | ||||||
| @@ -24,16 +32,19 @@ class AmazonSwitchEntityDescription(SwitchEntityDescription): | |||||||
|     """Alexa Devices switch entity description.""" |     """Alexa Devices switch entity description.""" | ||||||
|  |  | ||||||
|     is_on_fn: Callable[[AmazonDevice], bool] |     is_on_fn: Callable[[AmazonDevice], bool] | ||||||
|     subkey: str |     is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: ( | ||||||
|  |         device.online | ||||||
|  |         and (sensor := device.sensors.get(key)) is not None | ||||||
|  |         and sensor.error is False | ||||||
|  |     ) | ||||||
|     method: str |     method: str | ||||||
|  |  | ||||||
|  |  | ||||||
| SWITCHES: Final = ( | SWITCHES: Final = ( | ||||||
|     AmazonSwitchEntityDescription( |     AmazonSwitchEntityDescription( | ||||||
|         key="do_not_disturb", |         key="dnd", | ||||||
|         subkey="AUDIO_PLAYER", |  | ||||||
|         translation_key="do_not_disturb", |         translation_key="do_not_disturb", | ||||||
|         is_on_fn=lambda _device: _device.do_not_disturb, |         is_on_fn=lambda device: bool(device.sensors["dnd"].value), | ||||||
|         method="set_do_not_disturb", |         method="set_do_not_disturb", | ||||||
|     ), |     ), | ||||||
| ) | ) | ||||||
| @@ -48,13 +59,31 @@ async def async_setup_entry( | |||||||
|  |  | ||||||
|     coordinator = entry.runtime_data |     coordinator = entry.runtime_data | ||||||
|  |  | ||||||
|     async_add_entities( |     # Replace unique id for "DND" switch and remove from Speaker Group | ||||||
|         AmazonSwitchEntity(coordinator, serial_num, switch_desc) |     await async_update_unique_id( | ||||||
|         for switch_desc in SWITCHES |         hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd" | ||||||
|         for serial_num in coordinator.data |  | ||||||
|         if switch_desc.subkey in coordinator.data[serial_num].capabilities |  | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     # Remove DND switch from virtual groups | ||||||
|  |     await async_remove_dnd_from_virtual_group(hass, coordinator) | ||||||
|  |  | ||||||
|  |     known_devices: set[str] = set() | ||||||
|  |  | ||||||
|  |     def _check_device() -> None: | ||||||
|  |         current_devices = set(coordinator.data) | ||||||
|  |         new_devices = current_devices - known_devices | ||||||
|  |         if new_devices: | ||||||
|  |             known_devices.update(new_devices) | ||||||
|  |             async_add_entities( | ||||||
|  |                 AmazonSwitchEntity(coordinator, serial_num, switch_desc) | ||||||
|  |                 for switch_desc in SWITCHES | ||||||
|  |                 for serial_num in new_devices | ||||||
|  |                 if switch_desc.key in coordinator.data[serial_num].sensors | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |     _check_device() | ||||||
|  |     entry.async_on_unload(coordinator.async_add_listener(_check_device)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AmazonSwitchEntity(AmazonEntity, SwitchEntity): | class AmazonSwitchEntity(AmazonEntity, SwitchEntity): | ||||||
|     """Switch device.""" |     """Switch device.""" | ||||||
| @@ -84,3 +113,13 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity): | |||||||
|     def is_on(self) -> bool: |     def is_on(self) -> bool: | ||||||
|         """Return True if switch is on.""" |         """Return True if switch is on.""" | ||||||
|         return self.entity_description.is_on_fn(self.device) |         return self.entity_description.is_on_fn(self.device) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def available(self) -> bool: | ||||||
|  |         """Return if entity is available.""" | ||||||
|  |         return ( | ||||||
|  |             self.entity_description.is_available_fn( | ||||||
|  |                 self.device, self.entity_description.key | ||||||
|  |             ) | ||||||
|  |             and super().available | ||||||
|  |         ) | ||||||
|   | |||||||
| @@ -4,11 +4,16 @@ from collections.abc import Awaitable, Callable, Coroutine | |||||||
| from functools import wraps | from functools import wraps | ||||||
| from typing import Any, Concatenate | from typing import Any, Concatenate | ||||||
|  |  | ||||||
|  | from aioamazondevices.const import SPEAKER_GROUP_FAMILY | ||||||
| from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData | from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData | ||||||
|  |  | ||||||
|  | from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN | ||||||
|  | from homeassistant.core import HomeAssistant | ||||||
| from homeassistant.exceptions import HomeAssistantError | from homeassistant.exceptions import HomeAssistantError | ||||||
|  | import homeassistant.helpers.entity_registry as er | ||||||
|  |  | ||||||
| from .const import DOMAIN | from .const import _LOGGER, DOMAIN | ||||||
|  | from .coordinator import AmazonDevicesCoordinator | ||||||
| from .entity import AmazonEntity | from .entity import AmazonEntity | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -38,3 +43,41 @@ def alexa_api_call[_T: AmazonEntity, **_P]( | |||||||
|             ) from err |             ) from err | ||||||
|  |  | ||||||
|     return cmd_wrapper |     return cmd_wrapper | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def async_update_unique_id( | ||||||
|  |     hass: HomeAssistant, | ||||||
|  |     coordinator: AmazonDevicesCoordinator, | ||||||
|  |     domain: str, | ||||||
|  |     old_key: str, | ||||||
|  |     new_key: str, | ||||||
|  | ) -> None: | ||||||
|  |     """Update unique id for entities created with old format.""" | ||||||
|  |     entity_registry = er.async_get(hass) | ||||||
|  |  | ||||||
|  |     for serial_num in coordinator.data: | ||||||
|  |         unique_id = f"{serial_num}-{old_key}" | ||||||
|  |         if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id): | ||||||
|  |             _LOGGER.debug("Updating unique_id for %s", entity_id) | ||||||
|  |             new_unique_id = unique_id.replace(old_key, new_key) | ||||||
|  |  | ||||||
|  |             # Update the registry with the new unique_id | ||||||
|  |             entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def async_remove_dnd_from_virtual_group( | ||||||
|  |     hass: HomeAssistant, | ||||||
|  |     coordinator: AmazonDevicesCoordinator, | ||||||
|  | ) -> None: | ||||||
|  |     """Remove entity DND from virtual group.""" | ||||||
|  |     entity_registry = er.async_get(hass) | ||||||
|  |  | ||||||
|  |     for serial_num in coordinator.data: | ||||||
|  |         unique_id = f"{serial_num}-do_not_disturb" | ||||||
|  |         entity_id = entity_registry.async_get_entity_id( | ||||||
|  |             DOMAIN, SWITCH_DOMAIN, unique_id | ||||||
|  |         ) | ||||||
|  |         is_group = coordinator.data[serial_num].device_family == SPEAKER_GROUP_FAMILY | ||||||
|  |         if entity_id and is_group: | ||||||
|  |             entity_registry.async_remove(entity_id) | ||||||
|  |             _LOGGER.debug("Removed DND switch from virtual group %s", entity_id) | ||||||
|   | |||||||
| @@ -65,6 +65,31 @@ SENSOR_DESCRIPTIONS = [ | |||||||
|         suggested_display_precision=2, |         suggested_display_precision=2, | ||||||
|         translation_placeholders={"sensor_name": "BME280"}, |         translation_placeholders={"sensor_name": "BME280"}, | ||||||
|     ), |     ), | ||||||
|  |     AltruistSensorEntityDescription( | ||||||
|  |         device_class=SensorDeviceClass.HUMIDITY, | ||||||
|  |         key="BME680_humidity", | ||||||
|  |         translation_key="humidity", | ||||||
|  |         native_unit_of_measurement=PERCENTAGE, | ||||||
|  |         suggested_display_precision=2, | ||||||
|  |         translation_placeholders={"sensor_name": "BME680"}, | ||||||
|  |     ), | ||||||
|  |     AltruistSensorEntityDescription( | ||||||
|  |         device_class=SensorDeviceClass.PRESSURE, | ||||||
|  |         key="BME680_pressure", | ||||||
|  |         translation_key="pressure", | ||||||
|  |         native_unit_of_measurement=UnitOfPressure.PA, | ||||||
|  |         suggested_unit_of_measurement=UnitOfPressure.MMHG, | ||||||
|  |         suggested_display_precision=0, | ||||||
|  |         translation_placeholders={"sensor_name": "BME680"}, | ||||||
|  |     ), | ||||||
|  |     AltruistSensorEntityDescription( | ||||||
|  |         device_class=SensorDeviceClass.TEMPERATURE, | ||||||
|  |         key="BME680_temperature", | ||||||
|  |         translation_key="temperature", | ||||||
|  |         native_unit_of_measurement=UnitOfTemperature.CELSIUS, | ||||||
|  |         suggested_display_precision=2, | ||||||
|  |         translation_placeholders={"sensor_name": "BME680"}, | ||||||
|  |     ), | ||||||
|     AltruistSensorEntityDescription( |     AltruistSensorEntityDescription( | ||||||
|         device_class=SensorDeviceClass.PRESSURE, |         device_class=SensorDeviceClass.PRESSURE, | ||||||
|         key="BMP_pressure", |         key="BMP_pressure", | ||||||
|   | |||||||
| @@ -39,7 +39,7 @@ from homeassistant.helpers.hassio import is_hassio | |||||||
| from homeassistant.helpers.singleton import singleton | from homeassistant.helpers.singleton import singleton | ||||||
| from homeassistant.helpers.storage import Store | from homeassistant.helpers.storage import Store | ||||||
| from homeassistant.helpers.system_info import async_get_system_info | from homeassistant.helpers.system_info import async_get_system_info | ||||||
| from homeassistant.helpers.typing import UNDEFINED, UndefinedType | from homeassistant.helpers.typing import UNDEFINED | ||||||
| from homeassistant.loader import ( | from homeassistant.loader import ( | ||||||
|     Integration, |     Integration, | ||||||
|     IntegrationNotFound, |     IntegrationNotFound, | ||||||
| @@ -142,7 +142,6 @@ class EntityAnalyticsModifications: | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     remove: bool = False |     remove: bool = False | ||||||
|     capabilities: dict[str, Any] | None | UndefinedType = UNDEFINED |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AnalyticsPlatformProtocol(Protocol): | class AnalyticsPlatformProtocol(Protocol): | ||||||
| @@ -514,6 +513,8 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:  # noqa: C901 | |||||||
|     integration_inputs: dict[str, tuple[list[str], list[str]]] = {} |     integration_inputs: dict[str, tuple[list[str], list[str]]] = {} | ||||||
|     integration_configs: dict[str, AnalyticsModifications] = {} |     integration_configs: dict[str, AnalyticsModifications] = {} | ||||||
|  |  | ||||||
|  |     removed_devices: set[str] = set() | ||||||
|  |  | ||||||
|     # Get device list |     # Get device list | ||||||
|     for device_entry in dev_reg.devices.values(): |     for device_entry in dev_reg.devices.values(): | ||||||
|         if not device_entry.primary_config_entry: |         if not device_entry.primary_config_entry: | ||||||
| @@ -526,6 +527,10 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:  # noqa: C901 | |||||||
|         if config_entry is None: |         if config_entry is None: | ||||||
|             continue |             continue | ||||||
|  |  | ||||||
|  |         if device_entry.entry_type is dr.DeviceEntryType.SERVICE: | ||||||
|  |             removed_devices.add(device_entry.id) | ||||||
|  |             continue | ||||||
|  |  | ||||||
|         integration_domain = config_entry.domain |         integration_domain = config_entry.domain | ||||||
|  |  | ||||||
|         integration_input = integration_inputs.setdefault(integration_domain, ([], [])) |         integration_input = integration_inputs.setdefault(integration_domain, ([], [])) | ||||||
| @@ -538,6 +543,23 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:  # noqa: C901 | |||||||
|         integration_input = integration_inputs.setdefault(integration_domain, ([], [])) |         integration_input = integration_inputs.setdefault(integration_domain, ([], [])) | ||||||
|         integration_input[1].append(entity_entry.entity_id) |         integration_input[1].append(entity_entry.entity_id) | ||||||
|  |  | ||||||
|  |     integrations = { | ||||||
|  |         domain: integration | ||||||
|  |         for domain, integration in ( | ||||||
|  |             await async_get_integrations(hass, integration_inputs.keys()) | ||||||
|  |         ).items() | ||||||
|  |         if isinstance(integration, Integration) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     # Filter out custom integrations and integrations that are not device or hub type | ||||||
|  |     integration_inputs = { | ||||||
|  |         domain: integration_info | ||||||
|  |         for domain, integration_info in integration_inputs.items() | ||||||
|  |         if (integration := integrations.get(domain)) is not None | ||||||
|  |         and integration.is_built_in | ||||||
|  |         and integration.manifest.get("integration_type") in ("device", "hub") | ||||||
|  |     } | ||||||
|  |  | ||||||
|     # Call integrations that implement the analytics platform |     # Call integrations that implement the analytics platform | ||||||
|     for integration_domain, integration_input in integration_inputs.items(): |     for integration_domain, integration_input in integration_inputs.items(): | ||||||
|         if ( |         if ( | ||||||
| @@ -598,15 +620,15 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:  # noqa: C901 | |||||||
|                 device_config = integration_config.devices.get(device_id, device_config) |                 device_config = integration_config.devices.get(device_id, device_config) | ||||||
|  |  | ||||||
|             if device_config.remove: |             if device_config.remove: | ||||||
|  |                 removed_devices.add(device_id) | ||||||
|                 continue |                 continue | ||||||
|  |  | ||||||
|             device_entry = dev_reg.devices[device_id] |             device_entry = dev_reg.devices[device_id] | ||||||
|  |  | ||||||
|             device_id_mapping[device_entry.id] = (integration_domain, len(devices_info)) |             device_id_mapping[device_id] = (integration_domain, len(devices_info)) | ||||||
|  |  | ||||||
|             devices_info.append( |             devices_info.append( | ||||||
|                 { |                 { | ||||||
|                     "entities": [], |  | ||||||
|                     "entry_type": device_entry.entry_type, |                     "entry_type": device_entry.entry_type, | ||||||
|                     "has_configuration_url": device_entry.configuration_url is not None, |                     "has_configuration_url": device_entry.configuration_url is not None, | ||||||
|                     "hw_version": device_entry.hw_version, |                     "hw_version": device_entry.hw_version, | ||||||
| @@ -615,6 +637,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:  # noqa: C901 | |||||||
|                     "model_id": device_entry.model_id, |                     "model_id": device_entry.model_id, | ||||||
|                     "sw_version": device_entry.sw_version, |                     "sw_version": device_entry.sw_version, | ||||||
|                     "via_device": device_entry.via_device_id, |                     "via_device": device_entry.via_device_id, | ||||||
|  |                     "entities": [], | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
| @@ -653,57 +676,40 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:  # noqa: C901 | |||||||
|  |  | ||||||
|             entity_entry = ent_reg.entities[entity_id] |             entity_entry = ent_reg.entities[entity_id] | ||||||
|  |  | ||||||
|             entity_state = hass.states.get(entity_entry.entity_id) |             entity_state = hass.states.get(entity_id) | ||||||
|  |  | ||||||
|             entity_info = { |             entity_info = { | ||||||
|                 # LIMITATION: `assumed_state` can be overridden by users; |                 # LIMITATION: `assumed_state` can be overridden by users; | ||||||
|                 # we should replace it with the original value in the future. |                 # we should replace it with the original value in the future. | ||||||
|                 # It is also not present, if entity is not in the state machine, |                 # It is also not present, if entity is not in the state machine, | ||||||
|                 # which can happen for disabled entities. |                 # which can happen for disabled entities. | ||||||
|                 "assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False) |                 "assumed_state": ( | ||||||
|                 if entity_state is not None |                     entity_state.attributes.get(ATTR_ASSUMED_STATE, False) | ||||||
|                 else None, |                     if entity_state is not None | ||||||
|                 "capabilities": entity_config.capabilities |                     else None | ||||||
|                 if entity_config.capabilities is not UNDEFINED |                 ), | ||||||
|                 else entity_entry.capabilities, |  | ||||||
|                 "domain": entity_entry.domain, |                 "domain": entity_entry.domain, | ||||||
|                 "entity_category": entity_entry.entity_category, |                 "entity_category": entity_entry.entity_category, | ||||||
|                 "has_entity_name": entity_entry.has_entity_name, |                 "has_entity_name": entity_entry.has_entity_name, | ||||||
|                 "modified_by_integration": ["capabilities"] |  | ||||||
|                 if entity_config.capabilities is not UNDEFINED |  | ||||||
|                 else None, |  | ||||||
|                 "original_device_class": entity_entry.original_device_class, |                 "original_device_class": entity_entry.original_device_class, | ||||||
|                 # LIMITATION: `unit_of_measurement` can be overridden by users; |                 # LIMITATION: `unit_of_measurement` can be overridden by users; | ||||||
|                 # we should replace it with the original value in the future. |                 # we should replace it with the original value in the future. | ||||||
|                 "unit_of_measurement": entity_entry.unit_of_measurement, |                 "unit_of_measurement": entity_entry.unit_of_measurement, | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             if ( |             if (device_id_ := entity_entry.device_id) is not None: | ||||||
|                 ((device_id_ := entity_entry.device_id) is not None) |                 if device_id_ in removed_devices: | ||||||
|                 and ((new_device_id := device_id_mapping.get(device_id_)) is not None) |                     # The device was removed, so we remove the entity too | ||||||
|                 and (new_device_id[0] == integration_domain) |                     continue | ||||||
|             ): |  | ||||||
|                 device_info = devices_info[new_device_id[1]] |  | ||||||
|                 device_info["entities"].append(entity_info) |  | ||||||
|             else: |  | ||||||
|                 entities_info.append(entity_info) |  | ||||||
|  |  | ||||||
|     integrations = { |                 if ( | ||||||
|         domain: integration |                     new_device_id := device_id_mapping.get(device_id_) | ||||||
|         for domain, integration in ( |                 ) is not None and (new_device_id[0] == integration_domain): | ||||||
|             await async_get_integrations(hass, integrations_info.keys()) |                     device_info = devices_info[new_device_id[1]] | ||||||
|         ).items() |                     device_info["entities"].append(entity_info) | ||||||
|         if isinstance(integration, Integration) |                     continue | ||||||
|     } |  | ||||||
|  |  | ||||||
|     for domain, integration_info in integrations_info.items(): |             entities_info.append(entity_info) | ||||||
|         if integration := integrations.get(domain): |  | ||||||
|             integration_info["is_custom_integration"] = not integration.is_built_in |  | ||||||
|             # Include version for custom integrations |  | ||||||
|             if not integration.is_built_in and integration.version: |  | ||||||
|                 integration_info["custom_integration_version"] = str( |  | ||||||
|                     integration.version |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|     return { |     return { | ||||||
|         "version": "home-assistant:1", |         "version": "home-assistant:1", | ||||||
|   | |||||||
| @@ -19,9 +19,8 @@ CONF_THINKING_BUDGET = "thinking_budget" | |||||||
| RECOMMENDED_THINKING_BUDGET = 0 | RECOMMENDED_THINKING_BUDGET = 0 | ||||||
| MIN_THINKING_BUDGET = 1024 | MIN_THINKING_BUDGET = 1024 | ||||||
|  |  | ||||||
| THINKING_MODELS = [ | NON_THINKING_MODELS = [ | ||||||
|     "claude-3-7-sonnet", |     "claude-3-5",  # Both sonnet and haiku | ||||||
|     "claude-sonnet-4-0", |     "claude-3-opus", | ||||||
|     "claude-opus-4-0", |     "claude-3-haiku", | ||||||
|     "claude-opus-4-1", |  | ||||||
| ] | ] | ||||||
|   | |||||||
| @@ -51,11 +51,11 @@ from .const import ( | |||||||
|     DOMAIN, |     DOMAIN, | ||||||
|     LOGGER, |     LOGGER, | ||||||
|     MIN_THINKING_BUDGET, |     MIN_THINKING_BUDGET, | ||||||
|  |     NON_THINKING_MODELS, | ||||||
|     RECOMMENDED_CHAT_MODEL, |     RECOMMENDED_CHAT_MODEL, | ||||||
|     RECOMMENDED_MAX_TOKENS, |     RECOMMENDED_MAX_TOKENS, | ||||||
|     RECOMMENDED_TEMPERATURE, |     RECOMMENDED_TEMPERATURE, | ||||||
|     RECOMMENDED_THINKING_BUDGET, |     RECOMMENDED_THINKING_BUDGET, | ||||||
|     THINKING_MODELS, |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| # Max number of back and forth with the LLM to generate a response | # Max number of back and forth with the LLM to generate a response | ||||||
| @@ -364,7 +364,7 @@ class AnthropicBaseLLMEntity(Entity): | |||||||
|         if tools: |         if tools: | ||||||
|             model_args["tools"] = tools |             model_args["tools"] = tools | ||||||
|         if ( |         if ( | ||||||
|             model.startswith(tuple(THINKING_MODELS)) |             not model.startswith(tuple(NON_THINKING_MODELS)) | ||||||
|             and thinking_budget >= MIN_THINKING_BUDGET |             and thinking_budget >= MIN_THINKING_BUDGET | ||||||
|         ): |         ): | ||||||
|             model_args["thinking"] = ThinkingConfigEnabledParam( |             model_args["thinking"] = ThinkingConfigEnabledParam( | ||||||
|   | |||||||
| @@ -8,5 +8,5 @@ | |||||||
|   "documentation": "https://www.home-assistant.io/integrations/anthropic", |   "documentation": "https://www.home-assistant.io/integrations/anthropic", | ||||||
|   "integration_type": "service", |   "integration_type": "service", | ||||||
|   "iot_class": "cloud_polling", |   "iot_class": "cloud_polling", | ||||||
|   "requirements": ["anthropic==0.62.0"] |   "requirements": ["anthropic==0.69.0"] | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1308,7 +1308,9 @@ class PipelineRun: | |||||||
|                     # instead of a full response. |                     # instead of a full response. | ||||||
|                     all_targets_in_satellite_area = ( |                     all_targets_in_satellite_area = ( | ||||||
|                         self._get_all_targets_in_satellite_area( |                         self._get_all_targets_in_satellite_area( | ||||||
|                             conversation_result.response, self._device_id |                             conversation_result.response, | ||||||
|  |                             self._satellite_id, | ||||||
|  |                             self._device_id, | ||||||
|                         ) |                         ) | ||||||
|                     ) |                     ) | ||||||
|  |  | ||||||
| @@ -1337,39 +1339,62 @@ class PipelineRun: | |||||||
|         return (speech, all_targets_in_satellite_area) |         return (speech, all_targets_in_satellite_area) | ||||||
|  |  | ||||||
|     def _get_all_targets_in_satellite_area( |     def _get_all_targets_in_satellite_area( | ||||||
|         self, intent_response: intent.IntentResponse, device_id: str | None |         self, | ||||||
|  |         intent_response: intent.IntentResponse, | ||||||
|  |         satellite_id: str | None, | ||||||
|  |         device_id: str | None, | ||||||
|     ) -> bool: |     ) -> bool: | ||||||
|         """Return true if all targeted entities were in the same area as the device.""" |         """Return true if all targeted entities were in the same area as the device.""" | ||||||
|         if ( |         if ( | ||||||
|             (intent_response.response_type != intent.IntentResponseType.ACTION_DONE) |             intent_response.response_type != intent.IntentResponseType.ACTION_DONE | ||||||
|             or (not intent_response.matched_states) |             or not intent_response.matched_states | ||||||
|             or (not device_id) |  | ||||||
|         ): |  | ||||||
|             return False |  | ||||||
|  |  | ||||||
|         device_registry = dr.async_get(self.hass) |  | ||||||
|  |  | ||||||
|         if (not (device := device_registry.async_get(device_id))) or ( |  | ||||||
|             not device.area_id |  | ||||||
|         ): |         ): | ||||||
|             return False |             return False | ||||||
|  |  | ||||||
|         entity_registry = er.async_get(self.hass) |         entity_registry = er.async_get(self.hass) | ||||||
|         for state in intent_response.matched_states: |         device_registry = dr.async_get(self.hass) | ||||||
|             entity = entity_registry.async_get(state.entity_id) |  | ||||||
|             if not entity: |         area_id: str | None = None | ||||||
|  |  | ||||||
|  |         if ( | ||||||
|  |             satellite_id is not None | ||||||
|  |             and (target_entity_entry := entity_registry.async_get(satellite_id)) | ||||||
|  |             is not None | ||||||
|  |         ): | ||||||
|  |             area_id = target_entity_entry.area_id | ||||||
|  |             device_id = target_entity_entry.device_id | ||||||
|  |  | ||||||
|  |         if area_id is None: | ||||||
|  |             if device_id is None: | ||||||
|                 return False |                 return False | ||||||
|  |  | ||||||
|             if (entity_area_id := entity.area_id) is None: |             device_entry = device_registry.async_get(device_id) | ||||||
|                 if (entity.device_id is None) or ( |             if device_entry is None: | ||||||
|                     (entity_device := device_registry.async_get(entity.device_id)) |                 return False | ||||||
|                     is None |  | ||||||
|                 ): |             area_id = device_entry.area_id | ||||||
|  |             if area_id is None: | ||||||
|  |                 return False | ||||||
|  |  | ||||||
|  |         for state in intent_response.matched_states: | ||||||
|  |             target_entity_entry = entity_registry.async_get(state.entity_id) | ||||||
|  |             if target_entity_entry is None: | ||||||
|  |                 return False | ||||||
|  |  | ||||||
|  |             target_area_id = target_entity_entry.area_id | ||||||
|  |             if target_area_id is None: | ||||||
|  |                 if target_entity_entry.device_id is None: | ||||||
|                     return False |                     return False | ||||||
|  |  | ||||||
|                 entity_area_id = entity_device.area_id |                 target_device_entry = device_registry.async_get( | ||||||
|  |                     target_entity_entry.device_id | ||||||
|  |                 ) | ||||||
|  |                 if target_device_entry is None: | ||||||
|  |                     return False | ||||||
|  |  | ||||||
|             if entity_area_id != device.area_id: |                 target_area_id = target_device_entry.area_id | ||||||
|  |  | ||||||
|  |             if target_area_id != area_id: | ||||||
|                 return False |                 return False | ||||||
|  |  | ||||||
|         return True |         return True | ||||||
|   | |||||||
| @@ -2,9 +2,7 @@ | |||||||
|  |  | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
| from typing import Any, TypeVar | from typing import Any | ||||||
|  |  | ||||||
| T = TypeVar("T", dict[str, Any], list[Any], None) |  | ||||||
|  |  | ||||||
| TRANSLATION_MAP = { | TRANSLATION_MAP = { | ||||||
|     "wan_rx": "sensor_rx_bytes", |     "wan_rx": "sensor_rx_bytes", | ||||||
| @@ -36,7 +34,7 @@ def clean_dict(raw: dict[str, Any]) -> dict[str, Any]: | |||||||
|     return {k: v for k, v in raw.items() if v is not None or k.endswith("state")} |     return {k: v for k, v in raw.items() if v is not None or k.endswith("state")} | ||||||
|  |  | ||||||
|  |  | ||||||
| def translate_to_legacy(raw: T) -> T: | def translate_to_legacy[T: (dict[str, Any], list[Any], None)](raw: T) -> T: | ||||||
|     """Translate raw data to legacy format for dicts and lists.""" |     """Translate raw data to legacy format for dicts and lists.""" | ||||||
|  |  | ||||||
|     if raw is None: |     if raw is None: | ||||||
|   | |||||||
| @@ -1,24 +0,0 @@ | |||||||
| """Analytics platform.""" |  | ||||||
|  |  | ||||||
| from homeassistant.components.analytics import ( |  | ||||||
|     AnalyticsInput, |  | ||||||
|     AnalyticsModifications, |  | ||||||
|     EntityAnalyticsModifications, |  | ||||||
| ) |  | ||||||
| from homeassistant.core import HomeAssistant |  | ||||||
| from homeassistant.helpers import entity_registry as er |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def async_modify_analytics( |  | ||||||
|     hass: HomeAssistant, analytics_input: AnalyticsInput |  | ||||||
| ) -> AnalyticsModifications: |  | ||||||
|     """Modify the analytics.""" |  | ||||||
|     ent_reg = er.async_get(hass) |  | ||||||
|  |  | ||||||
|     entities: dict[str, EntityAnalyticsModifications] = {} |  | ||||||
|     for entity_id in analytics_input.entity_ids: |  | ||||||
|         entity_entry = ent_reg.entities[entity_id] |  | ||||||
|         if entity_entry.capabilities is not None: |  | ||||||
|             entities[entity_id] = EntityAnalyticsModifications(capabilities=None) |  | ||||||
|  |  | ||||||
|     return AnalyticsModifications(entities=entities) |  | ||||||
| @@ -26,9 +26,6 @@ async def async_setup_entry( | |||||||
|  |  | ||||||
|     if CONF_HOST in config_entry.data: |     if CONF_HOST in config_entry.data: | ||||||
|         coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session) |         coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session) | ||||||
|         config_entry.async_on_unload( |  | ||||||
|             config_entry.add_update_listener(_async_update_listener) |  | ||||||
|         ) |  | ||||||
|     else: |     else: | ||||||
|         coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session) |         coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session) | ||||||
|  |  | ||||||
| @@ -36,6 +33,11 @@ async def async_setup_entry( | |||||||
|  |  | ||||||
|     config_entry.runtime_data = coordinator |     config_entry.runtime_data = coordinator | ||||||
|  |  | ||||||
|  |     if CONF_HOST in config_entry.data: | ||||||
|  |         config_entry.async_on_unload( | ||||||
|  |             config_entry.add_update_listener(_async_update_listener) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) |     await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) | ||||||
|  |  | ||||||
|     return True |     return True | ||||||
|   | |||||||
| @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant, callback | |||||||
| from homeassistant.exceptions import HomeAssistantError | from homeassistant.exceptions import HomeAssistantError | ||||||
| from homeassistant.helpers import frame | from homeassistant.helpers import frame | ||||||
| from homeassistant.util import slugify | from homeassistant.util import slugify | ||||||
|  | from homeassistant.util.async_iterator import AsyncIteratorReader, AsyncIteratorWriter | ||||||
|  |  | ||||||
| from . import util | from . import util | ||||||
| from .agent import BackupAgent | from .agent import BackupAgent | ||||||
| @@ -144,7 +145,7 @@ class DownloadBackupView(HomeAssistantView): | |||||||
|                 return Response(status=HTTPStatus.NOT_FOUND) |                 return Response(status=HTTPStatus.NOT_FOUND) | ||||||
|         else: |         else: | ||||||
|             stream = await agent.async_download_backup(backup_id) |             stream = await agent.async_download_backup(backup_id) | ||||||
|             reader = cast(IO[bytes], util.AsyncIteratorReader(hass, stream)) |             reader = cast(IO[bytes], AsyncIteratorReader(hass.loop, stream)) | ||||||
|  |  | ||||||
|         worker_done_event = asyncio.Event() |         worker_done_event = asyncio.Event() | ||||||
|  |  | ||||||
| @@ -152,7 +153,7 @@ class DownloadBackupView(HomeAssistantView): | |||||||
|             """Call by the worker thread when it's done.""" |             """Call by the worker thread when it's done.""" | ||||||
|             hass.loop.call_soon_threadsafe(worker_done_event.set) |             hass.loop.call_soon_threadsafe(worker_done_event.set) | ||||||
|  |  | ||||||
|         stream = util.AsyncIteratorWriter(hass) |         stream = AsyncIteratorWriter(hass.loop) | ||||||
|         worker = threading.Thread( |         worker = threading.Thread( | ||||||
|             target=util.decrypt_backup, |             target=util.decrypt_backup, | ||||||
|             args=[backup, reader, stream, password, on_done, 0, []], |             args=[backup, reader, stream, password, on_done, 0, []], | ||||||
|   | |||||||
| @@ -38,6 +38,7 @@ from homeassistant.helpers import ( | |||||||
| ) | ) | ||||||
| from homeassistant.helpers.json import json_bytes | from homeassistant.helpers.json import json_bytes | ||||||
| from homeassistant.util import dt as dt_util, json as json_util | from homeassistant.util import dt as dt_util, json as json_util | ||||||
|  | from homeassistant.util.async_iterator import AsyncIteratorReader | ||||||
|  |  | ||||||
| from . import util as backup_util | from . import util as backup_util | ||||||
| from .agent import ( | from .agent import ( | ||||||
| @@ -72,7 +73,6 @@ from .models import ( | |||||||
| ) | ) | ||||||
| from .store import BackupStore | from .store import BackupStore | ||||||
| from .util import ( | from .util import ( | ||||||
|     AsyncIteratorReader, |  | ||||||
|     DecryptedBackupStreamer, |     DecryptedBackupStreamer, | ||||||
|     EncryptedBackupStreamer, |     EncryptedBackupStreamer, | ||||||
|     make_backup_dir, |     make_backup_dir, | ||||||
| @@ -1525,7 +1525,7 @@ class BackupManager: | |||||||
|             reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb") |             reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb") | ||||||
|         else: |         else: | ||||||
|             backup_stream = await agent.async_download_backup(backup_id) |             backup_stream = await agent.async_download_backup(backup_id) | ||||||
|             reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream)) |             reader = cast(IO[bytes], AsyncIteratorReader(self.hass.loop, backup_stream)) | ||||||
|         try: |         try: | ||||||
|             await self.hass.async_add_executor_job( |             await self.hass.async_add_executor_job( | ||||||
|                 validate_password_stream, reader, password |                 validate_password_stream, reader, password | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ from __future__ import annotations | |||||||
|  |  | ||||||
| import asyncio | import asyncio | ||||||
| from collections.abc import AsyncIterator, Callable, Coroutine | from collections.abc import AsyncIterator, Callable, Coroutine | ||||||
| from concurrent.futures import CancelledError, Future |  | ||||||
| import copy | import copy | ||||||
| from dataclasses import dataclass, replace | from dataclasses import dataclass, replace | ||||||
| from io import BytesIO | from io import BytesIO | ||||||
| @@ -14,7 +13,7 @@ from pathlib import Path, PurePath | |||||||
| from queue import SimpleQueue | from queue import SimpleQueue | ||||||
| import tarfile | import tarfile | ||||||
| import threading | import threading | ||||||
| from typing import IO, Any, Self, cast | from typing import IO, Any, cast | ||||||
|  |  | ||||||
| import aiohttp | import aiohttp | ||||||
| from securetar import SecureTarError, SecureTarFile, SecureTarReadError | from securetar import SecureTarError, SecureTarFile, SecureTarReadError | ||||||
| @@ -23,6 +22,11 @@ from homeassistant.backup_restore import password_to_key | |||||||
| from homeassistant.core import HomeAssistant | from homeassistant.core import HomeAssistant | ||||||
| from homeassistant.exceptions import HomeAssistantError | from homeassistant.exceptions import HomeAssistantError | ||||||
| from homeassistant.util import dt as dt_util | from homeassistant.util import dt as dt_util | ||||||
|  | from homeassistant.util.async_iterator import ( | ||||||
|  |     Abort, | ||||||
|  |     AsyncIteratorReader, | ||||||
|  |     AsyncIteratorWriter, | ||||||
|  | ) | ||||||
| from homeassistant.util.json import JsonObjectType, json_loads_object | from homeassistant.util.json import JsonObjectType, json_loads_object | ||||||
|  |  | ||||||
| from .const import BUF_SIZE, LOGGER | from .const import BUF_SIZE, LOGGER | ||||||
| @@ -59,12 +63,6 @@ class BackupEmpty(DecryptError): | |||||||
|     _message = "No tar files found in the backup." |     _message = "No tar files found in the backup." | ||||||
|  |  | ||||||
|  |  | ||||||
| class AbortCipher(HomeAssistantError): |  | ||||||
|     """Abort the cipher operation.""" |  | ||||||
|  |  | ||||||
|     _message = "Abort cipher operation." |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def make_backup_dir(path: Path) -> None: | def make_backup_dir(path: Path) -> None: | ||||||
|     """Create a backup directory if it does not exist.""" |     """Create a backup directory if it does not exist.""" | ||||||
|     path.mkdir(exist_ok=True) |     path.mkdir(exist_ok=True) | ||||||
| @@ -166,106 +164,6 @@ def validate_password(path: Path, password: str | None) -> bool: | |||||||
|     return False |     return False | ||||||
|  |  | ||||||
|  |  | ||||||
| class AsyncIteratorReader: |  | ||||||
|     """Wrap an AsyncIterator.""" |  | ||||||
|  |  | ||||||
|     def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None: |  | ||||||
|         """Initialize the wrapper.""" |  | ||||||
|         self._aborted = False |  | ||||||
|         self._hass = hass |  | ||||||
|         self._stream = stream |  | ||||||
|         self._buffer: bytes | None = None |  | ||||||
|         self._next_future: Future[bytes | None] | None = None |  | ||||||
|         self._pos: int = 0 |  | ||||||
|  |  | ||||||
|     async def _next(self) -> bytes | None: |  | ||||||
|         """Get the next chunk from the iterator.""" |  | ||||||
|         return await anext(self._stream, None) |  | ||||||
|  |  | ||||||
|     def abort(self) -> None: |  | ||||||
|         """Abort the reader.""" |  | ||||||
|         self._aborted = True |  | ||||||
|         if self._next_future is not None: |  | ||||||
|             self._next_future.cancel() |  | ||||||
|  |  | ||||||
|     def read(self, n: int = -1, /) -> bytes: |  | ||||||
|         """Read data from the iterator.""" |  | ||||||
|         result = bytearray() |  | ||||||
|         while n < 0 or len(result) < n: |  | ||||||
|             if not self._buffer: |  | ||||||
|                 self._next_future = asyncio.run_coroutine_threadsafe( |  | ||||||
|                     self._next(), self._hass.loop |  | ||||||
|                 ) |  | ||||||
|                 if self._aborted: |  | ||||||
|                     self._next_future.cancel() |  | ||||||
|                     raise AbortCipher |  | ||||||
|                 try: |  | ||||||
|                     self._buffer = self._next_future.result() |  | ||||||
|                 except CancelledError as err: |  | ||||||
|                     raise AbortCipher from err |  | ||||||
|                 self._pos = 0 |  | ||||||
|             if not self._buffer: |  | ||||||
|                 # The stream is exhausted |  | ||||||
|                 break |  | ||||||
|             chunk = self._buffer[self._pos : self._pos + n] |  | ||||||
|             result.extend(chunk) |  | ||||||
|             n -= len(chunk) |  | ||||||
|             self._pos += len(chunk) |  | ||||||
|             if self._pos == len(self._buffer): |  | ||||||
|                 self._buffer = None |  | ||||||
|         return bytes(result) |  | ||||||
|  |  | ||||||
|     def close(self) -> None: |  | ||||||
|         """Close the iterator.""" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AsyncIteratorWriter: |  | ||||||
|     """Wrap an AsyncIterator.""" |  | ||||||
|  |  | ||||||
|     def __init__(self, hass: HomeAssistant) -> None: |  | ||||||
|         """Initialize the wrapper.""" |  | ||||||
|         self._aborted = False |  | ||||||
|         self._hass = hass |  | ||||||
|         self._pos: int = 0 |  | ||||||
|         self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1) |  | ||||||
|         self._write_future: Future[bytes | None] | None = None |  | ||||||
|  |  | ||||||
|     def __aiter__(self) -> Self: |  | ||||||
|         """Return the iterator.""" |  | ||||||
|         return self |  | ||||||
|  |  | ||||||
|     async def __anext__(self) -> bytes: |  | ||||||
|         """Get the next chunk from the iterator.""" |  | ||||||
|         if data := await self._queue.get(): |  | ||||||
|             return data |  | ||||||
|         raise StopAsyncIteration |  | ||||||
|  |  | ||||||
|     def abort(self) -> None: |  | ||||||
|         """Abort the writer.""" |  | ||||||
|         self._aborted = True |  | ||||||
|         if self._write_future is not None: |  | ||||||
|             self._write_future.cancel() |  | ||||||
|  |  | ||||||
|     def tell(self) -> int: |  | ||||||
|         """Return the current position in the iterator.""" |  | ||||||
|         return self._pos |  | ||||||
|  |  | ||||||
|     def write(self, s: bytes, /) -> int: |  | ||||||
|         """Write data to the iterator.""" |  | ||||||
|         self._write_future = asyncio.run_coroutine_threadsafe( |  | ||||||
|             self._queue.put(s), self._hass.loop |  | ||||||
|         ) |  | ||||||
|         if self._aborted: |  | ||||||
|             self._write_future.cancel() |  | ||||||
|             raise AbortCipher |  | ||||||
|         try: |  | ||||||
|             self._write_future.result() |  | ||||||
|         except CancelledError as err: |  | ||||||
|             raise AbortCipher from err |  | ||||||
|         self._pos += len(s) |  | ||||||
|         return len(s) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def validate_password_stream( | def validate_password_stream( | ||||||
|     input_stream: IO[bytes], |     input_stream: IO[bytes], | ||||||
|     password: str | None, |     password: str | None, | ||||||
| @@ -342,7 +240,7 @@ def decrypt_backup( | |||||||
|         finally: |         finally: | ||||||
|             # Write an empty chunk to signal the end of the stream |             # Write an empty chunk to signal the end of the stream | ||||||
|             output_stream.write(b"") |             output_stream.write(b"") | ||||||
|     except AbortCipher: |     except Abort: | ||||||
|         LOGGER.debug("Cipher operation aborted") |         LOGGER.debug("Cipher operation aborted") | ||||||
|     finally: |     finally: | ||||||
|         on_done(error) |         on_done(error) | ||||||
| @@ -430,7 +328,7 @@ def encrypt_backup( | |||||||
|         finally: |         finally: | ||||||
|             # Write an empty chunk to signal the end of the stream |             # Write an empty chunk to signal the end of the stream | ||||||
|             output_stream.write(b"") |             output_stream.write(b"") | ||||||
|     except AbortCipher: |     except Abort: | ||||||
|         LOGGER.debug("Cipher operation aborted") |         LOGGER.debug("Cipher operation aborted") | ||||||
|     finally: |     finally: | ||||||
|         on_done(error) |         on_done(error) | ||||||
| @@ -557,8 +455,8 @@ class _CipherBackupStreamer: | |||||||
|             self._hass.loop.call_soon_threadsafe(worker_status.done.set) |             self._hass.loop.call_soon_threadsafe(worker_status.done.set) | ||||||
|  |  | ||||||
|         stream = await self._open_stream() |         stream = await self._open_stream() | ||||||
|         reader = AsyncIteratorReader(self._hass, stream) |         reader = AsyncIteratorReader(self._hass.loop, stream) | ||||||
|         writer = AsyncIteratorWriter(self._hass) |         writer = AsyncIteratorWriter(self._hass.loop) | ||||||
|         worker = threading.Thread( |         worker = threading.Thread( | ||||||
|             target=self._cipher_func, |             target=self._cipher_func, | ||||||
|             args=[ |             args=[ | ||||||
|   | |||||||
| @@ -73,11 +73,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) | |||||||
|     # Add the websocket and API client |     # Add the websocket and API client | ||||||
|     entry.runtime_data = BangOlufsenData(websocket, client) |     entry.runtime_data = BangOlufsenData(websocket, client) | ||||||
|  |  | ||||||
|     # Start WebSocket connection |  | ||||||
|     await client.connect_notifications(remote_control=True, reconnect=True) |  | ||||||
|  |  | ||||||
|     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) |     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||||||
|  |  | ||||||
|  |     # Start WebSocket connection once the platforms have been loaded. | ||||||
|  |     # This ensures that the initial WebSocket notifications are dispatched to entities | ||||||
|  |     await client.connect_notifications(remote_control=True, reconnect=True) | ||||||
|  |  | ||||||
|     return True |     return True | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -125,7 +125,8 @@ async def async_setup_entry( | |||||||
|     async_add_entities( |     async_add_entities( | ||||||
|         new_entities=[ |         new_entities=[ | ||||||
|             BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client) |             BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client) | ||||||
|         ] |         ], | ||||||
|  |         update_before_add=True, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # Register actions. |     # Register actions. | ||||||
| @@ -266,34 +267,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): | |||||||
|             self._software_status.software_version, |             self._software_status.software_version, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Get overall device state once. This is handled by WebSocket events the rest of the time. |  | ||||||
|         product_state = await self._client.get_product_state() |  | ||||||
|  |  | ||||||
|         # Get volume information. |  | ||||||
|         if product_state.volume: |  | ||||||
|             self._volume = product_state.volume |  | ||||||
|  |  | ||||||
|         # Get all playback information. |  | ||||||
|         # Ensure that the metadata is not None upon startup |  | ||||||
|         if product_state.playback: |  | ||||||
|             if product_state.playback.metadata: |  | ||||||
|                 self._playback_metadata = product_state.playback.metadata |  | ||||||
|                 self._remote_leader = product_state.playback.metadata.remote_leader |  | ||||||
|             if product_state.playback.progress: |  | ||||||
|                 self._playback_progress = product_state.playback.progress |  | ||||||
|             if product_state.playback.source: |  | ||||||
|                 self._source_change = product_state.playback.source |  | ||||||
|             if product_state.playback.state: |  | ||||||
|                 self._playback_state = product_state.playback.state |  | ||||||
|                 # Set initial state |  | ||||||
|                 if self._playback_state.value: |  | ||||||
|                     self._state = self._playback_state.value |  | ||||||
|  |  | ||||||
|         self._attr_media_position_updated_at = utcnow() |         self._attr_media_position_updated_at = utcnow() | ||||||
|  |  | ||||||
|         # Get the highest resolution available of the given images. |  | ||||||
|         self._media_image = get_highest_resolution_artwork(self._playback_metadata) |  | ||||||
|  |  | ||||||
|         # If the device has been updated with new sources, then the API will fail here. |         # If the device has been updated with new sources, then the API will fail here. | ||||||
|         await self._async_update_sources() |         await self._async_update_sources() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,16 +3,12 @@ beolink_allstandby: | |||||||
|     entity: |     entity: | ||||||
|       integration: bang_olufsen |       integration: bang_olufsen | ||||||
|       domain: media_player |       domain: media_player | ||||||
|     device: |  | ||||||
|       integration: bang_olufsen |  | ||||||
|  |  | ||||||
| beolink_expand: | beolink_expand: | ||||||
|   target: |   target: | ||||||
|     entity: |     entity: | ||||||
|       integration: bang_olufsen |       integration: bang_olufsen | ||||||
|       domain: media_player |       domain: media_player | ||||||
|     device: |  | ||||||
|       integration: bang_olufsen |  | ||||||
|   fields: |   fields: | ||||||
|     all_discovered: |     all_discovered: | ||||||
|       required: false |       required: false | ||||||
| @@ -37,8 +33,6 @@ beolink_join: | |||||||
|     entity: |     entity: | ||||||
|       integration: bang_olufsen |       integration: bang_olufsen | ||||||
|       domain: media_player |       domain: media_player | ||||||
|     device: |  | ||||||
|       integration: bang_olufsen |  | ||||||
|   fields: |   fields: | ||||||
|     jid_options: |     jid_options: | ||||||
|       collapsed: false |       collapsed: false | ||||||
| @@ -71,16 +65,12 @@ beolink_leave: | |||||||
|     entity: |     entity: | ||||||
|       integration: bang_olufsen |       integration: bang_olufsen | ||||||
|       domain: media_player |       domain: media_player | ||||||
|     device: |  | ||||||
|       integration: bang_olufsen |  | ||||||
|  |  | ||||||
| beolink_unexpand: | beolink_unexpand: | ||||||
|   target: |   target: | ||||||
|     entity: |     entity: | ||||||
|       integration: bang_olufsen |       integration: bang_olufsen | ||||||
|       domain: media_player |       domain: media_player | ||||||
|     device: |  | ||||||
|       integration: bang_olufsen |  | ||||||
|   fields: |   fields: | ||||||
|     jid_options: |     jid_options: | ||||||
|       collapsed: false |       collapsed: false | ||||||
|   | |||||||
| @@ -272,6 +272,13 @@ async def async_setup_entry( | |||||||
|     observations: list[ConfigType] = [ |     observations: list[ConfigType] = [ | ||||||
|         dict(subentry.data) for subentry in config_entry.subentries.values() |         dict(subentry.data) for subentry in config_entry.subentries.values() | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |     for observation in observations: | ||||||
|  |         if observation[CONF_PLATFORM] == CONF_TEMPLATE: | ||||||
|  |             observation[CONF_VALUE_TEMPLATE] = Template( | ||||||
|  |                 observation[CONF_VALUE_TEMPLATE], hass | ||||||
|  |             ) | ||||||
|  |  | ||||||
|     prior: float = config[CONF_PRIOR] |     prior: float = config[CONF_PRIOR] | ||||||
|     probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD] |     probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD] | ||||||
|     device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) |     device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) | ||||||
|   | |||||||
| @@ -13,20 +13,30 @@ from bluecurrent_api.exceptions import ( | |||||||
|     RequestLimitReached, |     RequestLimitReached, | ||||||
|     WebsocketError, |     WebsocketError, | ||||||
| ) | ) | ||||||
|  | import voluptuous as vol | ||||||
|  |  | ||||||
| from homeassistant.config_entries import ConfigEntry | from homeassistant.config_entries import ConfigEntry, ConfigEntryState | ||||||
| from homeassistant.const import CONF_API_TOKEN, Platform | from homeassistant.const import CONF_API_TOKEN, CONF_DEVICE_ID, Platform | ||||||
| from homeassistant.core import HomeAssistant | from homeassistant.core import HomeAssistant, ServiceCall | ||||||
| from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady | from homeassistant.exceptions import ( | ||||||
|  |     ConfigEntryAuthFailed, | ||||||
|  |     ConfigEntryNotReady, | ||||||
|  |     ServiceValidationError, | ||||||
|  | ) | ||||||
|  | from homeassistant.helpers import config_validation as cv, device_registry as dr | ||||||
| from homeassistant.helpers.dispatcher import async_dispatcher_send | from homeassistant.helpers.dispatcher import async_dispatcher_send | ||||||
|  | from homeassistant.helpers.typing import ConfigType | ||||||
|  |  | ||||||
| from .const import ( | from .const import ( | ||||||
|  |     BCU_APP, | ||||||
|     CHARGEPOINT_SETTINGS, |     CHARGEPOINT_SETTINGS, | ||||||
|     CHARGEPOINT_STATUS, |     CHARGEPOINT_STATUS, | ||||||
|  |     CHARGING_CARD_ID, | ||||||
|     DOMAIN, |     DOMAIN, | ||||||
|     EVSE_ID, |     EVSE_ID, | ||||||
|     LOGGER, |     LOGGER, | ||||||
|     PLUG_AND_CHARGE, |     PLUG_AND_CHARGE, | ||||||
|  |     SERVICE_START_CHARGE_SESSION, | ||||||
|     VALUE, |     VALUE, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -34,6 +44,7 @@ type BlueCurrentConfigEntry = ConfigEntry[Connector] | |||||||
|  |  | ||||||
| PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] | PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] | ||||||
| CHARGE_POINTS = "CHARGE_POINTS" | CHARGE_POINTS = "CHARGE_POINTS" | ||||||
|  | CHARGE_CARDS = "CHARGE_CARDS" | ||||||
| DATA = "data" | DATA = "data" | ||||||
| DELAY = 5 | DELAY = 5 | ||||||
|  |  | ||||||
| @@ -41,6 +52,16 @@ GRID = "GRID" | |||||||
| OBJECT = "object" | OBJECT = "object" | ||||||
| VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS] | VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS] | ||||||
|  |  | ||||||
|  | CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) | ||||||
|  |  | ||||||
|  | SERVICE_START_CHARGE_SESSION_SCHEMA = vol.Schema( | ||||||
|  |     { | ||||||
|  |         vol.Required(CONF_DEVICE_ID): cv.string, | ||||||
|  |         # When no charging card is provided, use no charging card (BCU_APP = no charging card). | ||||||
|  |         vol.Optional(CHARGING_CARD_ID, default=BCU_APP): cv.string, | ||||||
|  |     } | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| async def async_setup_entry( | async def async_setup_entry( | ||||||
|     hass: HomeAssistant, config_entry: BlueCurrentConfigEntry |     hass: HomeAssistant, config_entry: BlueCurrentConfigEntry | ||||||
| @@ -67,6 +88,66 @@ async def async_setup_entry( | |||||||
|     return True |     return True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: | ||||||
|  |     """Set up Blue Current.""" | ||||||
|  |  | ||||||
|  |     async def start_charge_session(service_call: ServiceCall) -> None: | ||||||
|  |         """Start a charge session with the provided device and charge card ID.""" | ||||||
|  |         # When no charge card is provided, use the default charge card set in the config flow. | ||||||
|  |         charging_card_id = service_call.data[CHARGING_CARD_ID] | ||||||
|  |         device_id = service_call.data[CONF_DEVICE_ID] | ||||||
|  |  | ||||||
|  |         # Get the device based on the given device ID. | ||||||
|  |         device = dr.async_get(hass).devices.get(device_id) | ||||||
|  |  | ||||||
|  |         if device is None: | ||||||
|  |             raise ServiceValidationError( | ||||||
|  |                 translation_domain=DOMAIN, translation_key="invalid_device_id" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         blue_current_config_entry: ConfigEntry | None = None | ||||||
|  |  | ||||||
|  |         for config_entry_id in device.config_entries: | ||||||
|  |             config_entry = hass.config_entries.async_get_entry(config_entry_id) | ||||||
|  |             if not config_entry or config_entry.domain != DOMAIN: | ||||||
|  |                 # Not the blue_current config entry. | ||||||
|  |                 continue | ||||||
|  |  | ||||||
|  |             if config_entry.state is not ConfigEntryState.LOADED: | ||||||
|  |                 raise ServiceValidationError( | ||||||
|  |                     translation_domain=DOMAIN, translation_key="config_entry_not_loaded" | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |             blue_current_config_entry = config_entry | ||||||
|  |             break | ||||||
|  |  | ||||||
|  |         if not blue_current_config_entry: | ||||||
|  |             # The device is not connected to a valid blue_current config entry. | ||||||
|  |             raise ServiceValidationError( | ||||||
|  |                 translation_domain=DOMAIN, translation_key="no_config_entry" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         connector = blue_current_config_entry.runtime_data | ||||||
|  |  | ||||||
|  |         # Get the evse_id from the identifier of the device. | ||||||
|  |         evse_id = next( | ||||||
|  |             identifier[1] | ||||||
|  |             for identifier in device.identifiers | ||||||
|  |             if identifier[0] == DOMAIN | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         await connector.client.start_session(evse_id, charging_card_id) | ||||||
|  |  | ||||||
|  |     hass.services.async_register( | ||||||
|  |         DOMAIN, | ||||||
|  |         SERVICE_START_CHARGE_SESSION, | ||||||
|  |         start_charge_session, | ||||||
|  |         SERVICE_START_CHARGE_SESSION_SCHEMA, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     return True | ||||||
|  |  | ||||||
|  |  | ||||||
| async def async_unload_entry( | async def async_unload_entry( | ||||||
|     hass: HomeAssistant, config_entry: BlueCurrentConfigEntry |     hass: HomeAssistant, config_entry: BlueCurrentConfigEntry | ||||||
| ) -> bool: | ) -> bool: | ||||||
| @@ -87,6 +168,7 @@ class Connector: | |||||||
|         self.client = client |         self.client = client | ||||||
|         self.charge_points: dict[str, dict] = {} |         self.charge_points: dict[str, dict] = {} | ||||||
|         self.grid: dict[str, Any] = {} |         self.grid: dict[str, Any] = {} | ||||||
|  |         self.charge_cards: dict[str, dict[str, Any]] = {} | ||||||
|  |  | ||||||
|     async def on_data(self, message: dict) -> None: |     async def on_data(self, message: dict) -> None: | ||||||
|         """Handle received data.""" |         """Handle received data.""" | ||||||
|   | |||||||
| @@ -8,6 +8,12 @@ LOGGER = logging.getLogger(__package__) | |||||||
|  |  | ||||||
| EVSE_ID = "evse_id" | EVSE_ID = "evse_id" | ||||||
| MODEL_TYPE = "model_type" | MODEL_TYPE = "model_type" | ||||||
|  | CARD = "card" | ||||||
|  | UID = "uid" | ||||||
|  | BCU_APP = "BCU-APP" | ||||||
|  | WITHOUT_CHARGING_CARD = "without_charging_card" | ||||||
|  | CHARGING_CARD_ID = "charging_card_id" | ||||||
|  | SERVICE_START_CHARGE_SESSION = "start_charge_session" | ||||||
| PLUG_AND_CHARGE = "plug_and_charge" | PLUG_AND_CHARGE = "plug_and_charge" | ||||||
| VALUE = "value" | VALUE = "value" | ||||||
| PERMISSION = "permission" | PERMISSION = "permission" | ||||||
|   | |||||||
| @@ -42,5 +42,10 @@ | |||||||
|         "default": "mdi:lock" |         "default": "mdi:lock" | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |   }, | ||||||
|  |   "services": { | ||||||
|  |     "start_charge_session": { | ||||||
|  |       "service": "mdi:play" | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								homeassistant/components/blue_current/services.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								homeassistant/components/blue_current/services.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | start_charge_session: | ||||||
|  |   fields: | ||||||
|  |     device_id: | ||||||
|  |       selector: | ||||||
|  |         device: | ||||||
|  |           integration: blue_current | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |     charging_card_id: | ||||||
|  |       selector: | ||||||
|  |         text: | ||||||
|  |       required: false | ||||||
| @@ -22,6 +22,16 @@ | |||||||
|       "wrong_account": "Wrong account: Please authenticate with the API token for {email}." |       "wrong_account": "Wrong account: Please authenticate with the API token for {email}." | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |   "options": { | ||||||
|  |     "step": { | ||||||
|  |       "init": { | ||||||
|  |         "data": { | ||||||
|  |           "card": "Card" | ||||||
|  |         }, | ||||||
|  |         "description": "Select the default charging card you want to use" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|   "entity": { |   "entity": { | ||||||
|     "sensor": { |     "sensor": { | ||||||
|       "activity": { |       "activity": { | ||||||
| @@ -136,5 +146,39 @@ | |||||||
|         "name": "Block charge point" |         "name": "Block charge point" | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |   }, | ||||||
|  |   "selector": { | ||||||
|  |     "select_charging_card": { | ||||||
|  |       "options": { | ||||||
|  |         "without_charging_card": "Without charging card" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "services": { | ||||||
|  |     "start_charge_session": { | ||||||
|  |       "name": "Start charge session", | ||||||
|  |       "description": "Starts a new charge session on a specified charge point.", | ||||||
|  |       "fields": { | ||||||
|  |         "charging_card_id": { | ||||||
|  |           "name": "Charging card ID", | ||||||
|  |           "description": "Optional charging card ID that will be used to start a charge session. When not provided, no charging card will be used." | ||||||
|  |         }, | ||||||
|  |         "device_id": { | ||||||
|  |           "name": "Device ID", | ||||||
|  |           "description": "The ID of the Blue Current charge point." | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "exceptions": { | ||||||
|  |     "invalid_device_id": { | ||||||
|  |       "message": "Invalid device ID given." | ||||||
|  |     }, | ||||||
|  |     "config_entry_not_loaded": { | ||||||
|  |       "message": "Config entry not loaded." | ||||||
|  |     }, | ||||||
|  |     "no_config_entry": { | ||||||
|  |       "message": "Device has not a valid blue_current config entry." | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ from asyncio import Future | |||||||
| from collections.abc import Callable, Iterable | from collections.abc import Callable, Iterable | ||||||
| from typing import TYPE_CHECKING, cast | from typing import TYPE_CHECKING, cast | ||||||
|  |  | ||||||
|  | from bleak import BleakScanner | ||||||
| from habluetooth import ( | from habluetooth import ( | ||||||
|     BaseHaScanner, |     BaseHaScanner, | ||||||
|     BluetoothScannerDevice, |     BluetoothScannerDevice, | ||||||
| @@ -38,13 +39,16 @@ def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager: | |||||||
|  |  | ||||||
|  |  | ||||||
| @hass_callback | @hass_callback | ||||||
| def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper: | def async_get_scanner(hass: HomeAssistant) -> BleakScanner: | ||||||
|     """Return a HaBleakScannerWrapper. |     """Return a HaBleakScannerWrapper cast to BleakScanner. | ||||||
|  |  | ||||||
|     This is a wrapper around our BleakScanner singleton that allows |     This is a wrapper around our BleakScanner singleton that allows | ||||||
|     multiple integrations to share the same BleakScanner. |     multiple integrations to share the same BleakScanner. | ||||||
|  |  | ||||||
|  |     The wrapper is cast to BleakScanner for type compatibility with | ||||||
|  |     libraries expecting a BleakScanner instance. | ||||||
|     """ |     """ | ||||||
|     return HaBleakScannerWrapper() |     return cast(BleakScanner, HaBleakScannerWrapper()) | ||||||
|  |  | ||||||
|  |  | ||||||
| @hass_callback | @hass_callback | ||||||
|   | |||||||
| @@ -19,8 +19,8 @@ | |||||||
|     "bleak-retry-connector==4.4.3", |     "bleak-retry-connector==4.4.3", | ||||||
|     "bluetooth-adapters==2.1.0", |     "bluetooth-adapters==2.1.0", | ||||||
|     "bluetooth-auto-recovery==1.5.3", |     "bluetooth-auto-recovery==1.5.3", | ||||||
|     "bluetooth-data-tools==1.28.2", |     "bluetooth-data-tools==1.28.3", | ||||||
|     "dbus-fast==2.44.3", |     "dbus-fast==2.44.5", | ||||||
|     "habluetooth==5.6.4" |     "habluetooth==5.7.0" | ||||||
|   ] |   ] | ||||||
| } | } | ||||||
|   | |||||||
| @@ -315,9 +315,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: | |||||||
|     hass.http.register_view(CalendarListView(component)) |     hass.http.register_view(CalendarListView(component)) | ||||||
|     hass.http.register_view(CalendarEventView(component)) |     hass.http.register_view(CalendarEventView(component)) | ||||||
|  |  | ||||||
|     frontend.async_register_built_in_panel( |     frontend.async_register_built_in_panel(hass, "calendar", "calendar", "mdi:calendar") | ||||||
|         hass, "calendar", "calendar", "hass:calendar" |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     websocket_api.async_register_command(hass, handle_calendar_event_create) |     websocket_api.async_register_command(hass, handle_calendar_event_create) | ||||||
|     websocket_api.async_register_command(hass, handle_calendar_event_delete) |     websocket_api.async_register_command(hass, handle_calendar_event_delete) | ||||||
|   | |||||||
| @@ -51,12 +51,6 @@ from homeassistant.const import ( | |||||||
| from homeassistant.core import Event, HomeAssistant, ServiceCall, callback | from homeassistant.core import Event, HomeAssistant, ServiceCall, callback | ||||||
| from homeassistant.exceptions import HomeAssistantError | from homeassistant.exceptions import HomeAssistantError | ||||||
| from homeassistant.helpers import config_validation as cv, issue_registry as ir | from homeassistant.helpers import config_validation as cv, issue_registry as ir | ||||||
| from homeassistant.helpers.deprecation import ( |  | ||||||
|     DeprecatedConstantEnum, |  | ||||||
|     all_with_deprecated_constants, |  | ||||||
|     check_if_deprecated_constant, |  | ||||||
|     dir_with_deprecated_constants, |  | ||||||
| ) |  | ||||||
| from homeassistant.helpers.entity import Entity, EntityDescription | from homeassistant.helpers.entity import Entity, EntityDescription | ||||||
| from homeassistant.helpers.entity_component import EntityComponent | from homeassistant.helpers.entity_component import EntityComponent | ||||||
| from homeassistant.helpers.event import async_track_time_interval | from homeassistant.helpers.event import async_track_time_interval | ||||||
| @@ -118,12 +112,6 @@ ATTR_FILENAME: Final = "filename" | |||||||
| ATTR_MEDIA_PLAYER: Final = "media_player" | ATTR_MEDIA_PLAYER: Final = "media_player" | ||||||
| ATTR_FORMAT: Final = "format" | ATTR_FORMAT: Final = "format" | ||||||
|  |  | ||||||
| # These constants are deprecated as of Home Assistant 2024.10 |  | ||||||
| # Please use the StreamType enum instead. |  | ||||||
| _DEPRECATED_STATE_RECORDING = DeprecatedConstantEnum(CameraState.RECORDING, "2025.10") |  | ||||||
| _DEPRECATED_STATE_STREAMING = DeprecatedConstantEnum(CameraState.STREAMING, "2025.10") |  | ||||||
| _DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(CameraState.IDLE, "2025.10") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class CameraEntityFeature(IntFlag): | class CameraEntityFeature(IntFlag): | ||||||
|     """Supported features of the camera entity.""" |     """Supported features of the camera entity.""" | ||||||
| @@ -1117,11 +1105,3 @@ async def async_handle_record_service( | |||||||
|         duration=service_call.data[CONF_DURATION], |         duration=service_call.data[CONF_DURATION], | ||||||
|         lookback=service_call.data[CONF_LOOKBACK], |         lookback=service_call.data[CONF_LOOKBACK], | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| # These can be removed if no deprecated constant are in this module anymore |  | ||||||
| __getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) |  | ||||||
| __dir__ = partial( |  | ||||||
|     dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] |  | ||||||
| ) |  | ||||||
| __all__ = all_with_deprecated_constants(globals()) |  | ||||||
|   | |||||||
| @@ -53,7 +53,6 @@ from .const import ( | |||||||
|     CONF_ACME_SERVER, |     CONF_ACME_SERVER, | ||||||
|     CONF_ALEXA, |     CONF_ALEXA, | ||||||
|     CONF_ALIASES, |     CONF_ALIASES, | ||||||
|     CONF_CLOUDHOOK_SERVER, |  | ||||||
|     CONF_COGNITO_CLIENT_ID, |     CONF_COGNITO_CLIENT_ID, | ||||||
|     CONF_ENTITY_CONFIG, |     CONF_ENTITY_CONFIG, | ||||||
|     CONF_FILTER, |     CONF_FILTER, | ||||||
| @@ -130,7 +129,6 @@ CONFIG_SCHEMA = vol.Schema( | |||||||
|                 vol.Optional(CONF_ACCOUNT_LINK_SERVER): str, |                 vol.Optional(CONF_ACCOUNT_LINK_SERVER): str, | ||||||
|                 vol.Optional(CONF_ACCOUNTS_SERVER): str, |                 vol.Optional(CONF_ACCOUNTS_SERVER): str, | ||||||
|                 vol.Optional(CONF_ACME_SERVER): str, |                 vol.Optional(CONF_ACME_SERVER): str, | ||||||
|                 vol.Optional(CONF_CLOUDHOOK_SERVER): str, |  | ||||||
|                 vol.Optional(CONF_RELAYER_SERVER): str, |                 vol.Optional(CONF_RELAYER_SERVER): str, | ||||||
|                 vol.Optional(CONF_REMOTESTATE_SERVER): str, |                 vol.Optional(CONF_REMOTESTATE_SERVER): str, | ||||||
|                 vol.Optional(CONF_SERVICEHANDLERS_SERVER): str, |                 vol.Optional(CONF_SERVICEHANDLERS_SERVER): str, | ||||||
|   | |||||||
| @@ -78,7 +78,6 @@ CONF_USER_POOL_ID = "user_pool_id" | |||||||
| CONF_ACCOUNT_LINK_SERVER = "account_link_server" | CONF_ACCOUNT_LINK_SERVER = "account_link_server" | ||||||
| CONF_ACCOUNTS_SERVER = "accounts_server" | CONF_ACCOUNTS_SERVER = "accounts_server" | ||||||
| CONF_ACME_SERVER = "acme_server" | CONF_ACME_SERVER = "acme_server" | ||||||
| CONF_CLOUDHOOK_SERVER = "cloudhook_server" |  | ||||||
| CONF_RELAYER_SERVER = "relayer_server" | CONF_RELAYER_SERVER = "relayer_server" | ||||||
| CONF_REMOTESTATE_SERVER = "remotestate_server" | CONF_REMOTESTATE_SERVER = "remotestate_server" | ||||||
| CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" | CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" | ||||||
|   | |||||||
| @@ -13,6 +13,6 @@ | |||||||
|   "integration_type": "system", |   "integration_type": "system", | ||||||
|   "iot_class": "cloud_push", |   "iot_class": "cloud_push", | ||||||
|   "loggers": ["acme", "hass_nabucasa", "snitun"], |   "loggers": ["acme", "hass_nabucasa", "snitun"], | ||||||
|   "requirements": ["hass-nabucasa==1.1.1"], |   "requirements": ["hass-nabucasa==1.2.0"], | ||||||
|   "single_config_entry": true |   "single_config_entry": true | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										106
									
								
								homeassistant/components/co2signal/quality_scale.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								homeassistant/components/co2signal/quality_scale.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,106 @@ | |||||||
|  | rules: | ||||||
|  |   # Bronze | ||||||
|  |   action-setup: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       The integration does not provide any actions. | ||||||
|  |   appropriate-polling: done | ||||||
|  |   brands: done | ||||||
|  |   common-modules: done | ||||||
|  |   config-flow-test-coverage: | ||||||
|  |     status: todo | ||||||
|  |     comment: | | ||||||
|  |       Stale docstring and test name: `test_form_home` and reusing result. | ||||||
|  |       Extract `async_setup_entry` into own fixture. | ||||||
|  |       Avoid importing `config_flow` in tests. | ||||||
|  |       Test reauth with errors | ||||||
|  |   config-flow: | ||||||
|  |     status: todo | ||||||
|  |     comment: | | ||||||
|  |       The config flow misses data descriptions. | ||||||
|  |       Remove URLs from data descriptions, they should be replaced with placeholders. | ||||||
|  |       Make use of Electricity Maps zone keys in country code as dropdown. | ||||||
|  |       Make use of location selector for coordinates. | ||||||
|  |   dependency-transparency: done | ||||||
|  |   docs-actions: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       The integration does not provide any actions. | ||||||
|  |   docs-high-level-description: done | ||||||
|  |   docs-installation-instructions: done | ||||||
|  |   docs-removal-instructions: done | ||||||
|  |   entity-event-setup: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       Entities of this integration do not explicitly subscribe to events. | ||||||
|  |   entity-unique-id: done | ||||||
|  |   has-entity-name: done | ||||||
|  |   runtime-data: done | ||||||
|  |   test-before-configure: done | ||||||
|  |   test-before-setup: done | ||||||
|  |   unique-config-entry: todo | ||||||
|  |  | ||||||
|  |   # Silver | ||||||
|  |   action-exceptions: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       The integration does not provide any actions. | ||||||
|  |   config-entry-unloading: done | ||||||
|  |   docs-configuration-parameters: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       The integration does not provide any additional options. | ||||||
|  |   docs-installation-parameters: done | ||||||
|  |   entity-unavailable: done | ||||||
|  |   integration-owner: done | ||||||
|  |   log-when-unavailable: done | ||||||
|  |   parallel-updates: todo | ||||||
|  |   reauthentication-flow: done | ||||||
|  |   test-coverage: | ||||||
|  |     status: todo | ||||||
|  |     comment: | | ||||||
|  |       Use `hass.config_entries.async_setup` instead of assert await `async_setup_component(hass, DOMAIN, {})` | ||||||
|  |       `test_sensor` could use `snapshot_platform` | ||||||
|  |  | ||||||
|  |   # Gold | ||||||
|  |   devices: done | ||||||
|  |   diagnostics: done | ||||||
|  |   discovery-update-info: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       This integration cannot be discovered, it is a connecting to a cloud service. | ||||||
|  |   discovery: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       This integration cannot be discovered, it is a connecting to a cloud service. | ||||||
|  |   docs-data-update: done | ||||||
|  |   docs-examples: done | ||||||
|  |   docs-known-limitations: done | ||||||
|  |   docs-supported-devices: done | ||||||
|  |   docs-supported-functions: done | ||||||
|  |   docs-troubleshooting: done | ||||||
|  |   docs-use-cases: done | ||||||
|  |   dynamic-devices: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       The integration connects to a single service per configuration entry. | ||||||
|  |   entity-category: done | ||||||
|  |   entity-device-class: done | ||||||
|  |   entity-disabled-by-default: done | ||||||
|  |   entity-translations: done | ||||||
|  |   exception-translations: todo | ||||||
|  |   icon-translations: todo | ||||||
|  |   reconfiguration-flow: todo | ||||||
|  |   repair-issues: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       This integration does not raise any repairable issues. | ||||||
|  |   stale-devices: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       This integration connect to a single device per configuration entry. | ||||||
|  |  | ||||||
|  |   # Platinum | ||||||
|  |   async-dependency: done | ||||||
|  |   inject-websession: done | ||||||
|  |   strict-typing: done | ||||||
| @@ -29,10 +29,23 @@ async def async_setup_entry( | |||||||
|  |  | ||||||
|     coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) |     coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) | ||||||
|  |  | ||||||
|     async_add_entities( |     known_devices: set[int] = set() | ||||||
|         ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) |  | ||||||
|         for device in coordinator.data["alarm_zones"].values() |     def _check_device() -> None: | ||||||
|     ) |         current_devices = set(coordinator.data["alarm_zones"]) | ||||||
|  |         new_devices = current_devices - known_devices | ||||||
|  |         if new_devices: | ||||||
|  |             known_devices.update(new_devices) | ||||||
|  |             async_add_entities( | ||||||
|  |                 ComelitVedoBinarySensorEntity( | ||||||
|  |                     coordinator, device, config_entry.entry_id | ||||||
|  |                 ) | ||||||
|  |                 for device in coordinator.data["alarm_zones"].values() | ||||||
|  |                 if device.index in new_devices | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |     _check_device() | ||||||
|  |     config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ComelitVedoBinarySensorEntity( | class ComelitVedoBinarySensorEntity( | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ from __future__ import annotations | |||||||
|  |  | ||||||
| from asyncio.exceptions import TimeoutError | from asyncio.exceptions import TimeoutError | ||||||
| from collections.abc import Mapping | from collections.abc import Mapping | ||||||
|  | import re | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from aiocomelit import ( | from aiocomelit import ( | ||||||
| @@ -25,23 +26,22 @@ from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN | |||||||
| from .utils import async_client_session | from .utils import async_client_session | ||||||
|  |  | ||||||
| DEFAULT_HOST = "192.168.1.252" | DEFAULT_HOST = "192.168.1.252" | ||||||
| DEFAULT_PIN = 111111 | DEFAULT_PIN = "111111" | ||||||
|  |  | ||||||
|  |  | ||||||
| USER_SCHEMA = vol.Schema( | USER_SCHEMA = vol.Schema( | ||||||
|     { |     { | ||||||
|         vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, |         vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, | ||||||
|         vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, |         vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, | ||||||
|         vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, |         vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string, | ||||||
|         vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST), |         vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST), | ||||||
|     } |     } | ||||||
| ) | ) | ||||||
| STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.positive_int}) | STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string}) | ||||||
| STEP_RECONFIGURE = vol.Schema( | STEP_RECONFIGURE = vol.Schema( | ||||||
|     { |     { | ||||||
|         vol.Required(CONF_HOST): cv.string, |         vol.Required(CONF_HOST): cv.string, | ||||||
|         vol.Required(CONF_PORT): cv.port, |         vol.Required(CONF_PORT): cv.port, | ||||||
|         vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, |         vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string, | ||||||
|     } |     } | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -51,6 +51,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, | |||||||
|  |  | ||||||
|     api: ComelitCommonApi |     api: ComelitCommonApi | ||||||
|  |  | ||||||
|  |     if not re.fullmatch(r"[0-9]{4,10}", data[CONF_PIN]): | ||||||
|  |         raise InvalidPin | ||||||
|  |  | ||||||
|     session = await async_client_session(hass) |     session = await async_client_session(hass) | ||||||
|     if data.get(CONF_TYPE, BRIDGE) == BRIDGE: |     if data.get(CONF_TYPE, BRIDGE) == BRIDGE: | ||||||
|         api = ComeliteSerialBridgeApi( |         api = ComeliteSerialBridgeApi( | ||||||
| @@ -101,6 +104,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|             errors["base"] = "cannot_connect" |             errors["base"] = "cannot_connect" | ||||||
|         except InvalidAuth: |         except InvalidAuth: | ||||||
|             errors["base"] = "invalid_auth" |             errors["base"] = "invalid_auth" | ||||||
|  |         except InvalidPin: | ||||||
|  |             errors["base"] = "invalid_pin" | ||||||
|         except Exception:  # noqa: BLE001 |         except Exception:  # noqa: BLE001 | ||||||
|             _LOGGER.exception("Unexpected exception") |             _LOGGER.exception("Unexpected exception") | ||||||
|             errors["base"] = "unknown" |             errors["base"] = "unknown" | ||||||
| @@ -142,6 +147,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|                 errors["base"] = "cannot_connect" |                 errors["base"] = "cannot_connect" | ||||||
|             except InvalidAuth: |             except InvalidAuth: | ||||||
|                 errors["base"] = "invalid_auth" |                 errors["base"] = "invalid_auth" | ||||||
|  |             except InvalidPin: | ||||||
|  |                 errors["base"] = "invalid_pin" | ||||||
|             except Exception:  # noqa: BLE001 |             except Exception:  # noqa: BLE001 | ||||||
|                 _LOGGER.exception("Unexpected exception") |                 _LOGGER.exception("Unexpected exception") | ||||||
|                 errors["base"] = "unknown" |                 errors["base"] = "unknown" | ||||||
| @@ -185,6 +192,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): | |||||||
|             errors["base"] = "cannot_connect" |             errors["base"] = "cannot_connect" | ||||||
|         except InvalidAuth: |         except InvalidAuth: | ||||||
|             errors["base"] = "invalid_auth" |             errors["base"] = "invalid_auth" | ||||||
|  |         except InvalidPin: | ||||||
|  |             errors["base"] = "invalid_pin" | ||||||
|         except Exception:  # noqa: BLE001 |         except Exception:  # noqa: BLE001 | ||||||
|             _LOGGER.exception("Unexpected exception") |             _LOGGER.exception("Unexpected exception") | ||||||
|             errors["base"] = "unknown" |             errors["base"] = "unknown" | ||||||
| @@ -206,3 +215,7 @@ class CannotConnect(HomeAssistantError): | |||||||
|  |  | ||||||
| class InvalidAuth(HomeAssistantError): | class InvalidAuth(HomeAssistantError): | ||||||
|     """Error to indicate there is invalid auth.""" |     """Error to indicate there is invalid auth.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class InvalidPin(HomeAssistantError): | ||||||
|  |     """Error to indicate an invalid pin.""" | ||||||
|   | |||||||
| @@ -161,7 +161,7 @@ class ComelitSerialBridge( | |||||||
|         entry: ComelitConfigEntry, |         entry: ComelitConfigEntry, | ||||||
|         host: str, |         host: str, | ||||||
|         port: int, |         port: int, | ||||||
|         pin: int, |         pin: str, | ||||||
|         session: ClientSession, |         session: ClientSession, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         """Initialize the scanner.""" |         """Initialize the scanner.""" | ||||||
| @@ -195,7 +195,7 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]): | |||||||
|         entry: ComelitConfigEntry, |         entry: ComelitConfigEntry, | ||||||
|         host: str, |         host: str, | ||||||
|         port: int, |         port: int, | ||||||
|         pin: int, |         pin: str, | ||||||
|         session: ClientSession, |         session: ClientSession, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         """Initialize the scanner.""" |         """Initialize the scanner.""" | ||||||
|   | |||||||
| @@ -29,10 +29,21 @@ async def async_setup_entry( | |||||||
|  |  | ||||||
|     coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) |     coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) | ||||||
|  |  | ||||||
|     async_add_entities( |     known_devices: set[int] = set() | ||||||
|         ComelitCoverEntity(coordinator, device, config_entry.entry_id) |  | ||||||
|         for device in coordinator.data[COVER].values() |     def _check_device() -> None: | ||||||
|     ) |         current_devices = set(coordinator.data[COVER]) | ||||||
|  |         new_devices = current_devices - known_devices | ||||||
|  |         if new_devices: | ||||||
|  |             known_devices.update(new_devices) | ||||||
|  |             async_add_entities( | ||||||
|  |                 ComelitCoverEntity(coordinator, device, config_entry.entry_id) | ||||||
|  |                 for device in coordinator.data[COVER].values() | ||||||
|  |                 if device.index in new_devices | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |     _check_device() | ||||||
|  |     config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): | class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): | ||||||
|   | |||||||
| @@ -27,10 +27,21 @@ async def async_setup_entry( | |||||||
|  |  | ||||||
|     coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) |     coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) | ||||||
|  |  | ||||||
|     async_add_entities( |     known_devices: set[int] = set() | ||||||
|         ComelitLightEntity(coordinator, device, config_entry.entry_id) |  | ||||||
|         for device in coordinator.data[LIGHT].values() |     def _check_device() -> None: | ||||||
|     ) |         current_devices = set(coordinator.data[LIGHT]) | ||||||
|  |         new_devices = current_devices - known_devices | ||||||
|  |         if new_devices: | ||||||
|  |             known_devices.update(new_devices) | ||||||
|  |             async_add_entities( | ||||||
|  |                 ComelitLightEntity(coordinator, device, config_entry.entry_id) | ||||||
|  |                 for device in coordinator.data[LIGHT].values() | ||||||
|  |                 if device.index in new_devices | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |     _check_device() | ||||||
|  |     config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity): | class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity): | ||||||
|   | |||||||
| @@ -7,6 +7,6 @@ | |||||||
|   "integration_type": "hub", |   "integration_type": "hub", | ||||||
|   "iot_class": "local_polling", |   "iot_class": "local_polling", | ||||||
|   "loggers": ["aiocomelit"], |   "loggers": ["aiocomelit"], | ||||||
|   "quality_scale": "silver", |   "quality_scale": "platinum", | ||||||
|   "requirements": ["aiocomelit==0.12.3"] |   "requirements": ["aiocomelit==1.1.1"] | ||||||
| } | } | ||||||
|   | |||||||
| @@ -57,9 +57,7 @@ rules: | |||||||
|   docs-supported-functions: done |   docs-supported-functions: done | ||||||
|   docs-troubleshooting: done |   docs-troubleshooting: done | ||||||
|   docs-use-cases: done |   docs-use-cases: done | ||||||
|   dynamic-devices: |   dynamic-devices: done | ||||||
|     status: todo |  | ||||||
|     comment: missing implementation |  | ||||||
|   entity-category: |   entity-category: | ||||||
|     status: exempt |     status: exempt | ||||||
|     comment: no config or diagnostic entities |     comment: no config or diagnostic entities | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ from __future__ import annotations | |||||||
|  |  | ||||||
| from typing import Final, cast | from typing import Final, cast | ||||||
|  |  | ||||||
| from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject | from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject | ||||||
| from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState | from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState | ||||||
|  |  | ||||||
| from homeassistant.components.sensor import ( | from homeassistant.components.sensor import ( | ||||||
| @@ -65,15 +65,24 @@ async def async_setup_bridge_entry( | |||||||
|  |  | ||||||
|     coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) |     coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) | ||||||
|  |  | ||||||
|     entities: list[ComelitBridgeSensorEntity] = [] |     known_devices: set[int] = set() | ||||||
|     for device in coordinator.data[OTHER].values(): |  | ||||||
|         entities.extend( |     def _check_device() -> None: | ||||||
|             ComelitBridgeSensorEntity( |         current_devices = set(coordinator.data[OTHER]) | ||||||
|                 coordinator, device, config_entry.entry_id, sensor_desc |         new_devices = current_devices - known_devices | ||||||
|  |         if new_devices: | ||||||
|  |             known_devices.update(new_devices) | ||||||
|  |             async_add_entities( | ||||||
|  |                 ComelitBridgeSensorEntity( | ||||||
|  |                     coordinator, device, config_entry.entry_id, sensor_desc | ||||||
|  |                 ) | ||||||
|  |                 for sensor_desc in SENSOR_BRIDGE_TYPES | ||||||
|  |                 for device in coordinator.data[OTHER].values() | ||||||
|  |                 if device.index in new_devices | ||||||
|             ) |             ) | ||||||
|             for sensor_desc in SENSOR_BRIDGE_TYPES |  | ||||||
|         ) |     _check_device() | ||||||
|     async_add_entities(entities) |     config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) | ||||||
|  |  | ||||||
|  |  | ||||||
| async def async_setup_vedo_entry( | async def async_setup_vedo_entry( | ||||||
| @@ -85,15 +94,24 @@ async def async_setup_vedo_entry( | |||||||
|  |  | ||||||
|     coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) |     coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) | ||||||
|  |  | ||||||
|     entities: list[ComelitVedoSensorEntity] = [] |     known_devices: set[int] = set() | ||||||
|     for device in coordinator.data["alarm_zones"].values(): |  | ||||||
|         entities.extend( |     def _check_device() -> None: | ||||||
|             ComelitVedoSensorEntity( |         current_devices = set(coordinator.data["alarm_zones"]) | ||||||
|                 coordinator, device, config_entry.entry_id, sensor_desc |         new_devices = current_devices - known_devices | ||||||
|  |         if new_devices: | ||||||
|  |             known_devices.update(new_devices) | ||||||
|  |             async_add_entities( | ||||||
|  |                 ComelitVedoSensorEntity( | ||||||
|  |                     coordinator, device, config_entry.entry_id, sensor_desc | ||||||
|  |                 ) | ||||||
|  |                 for sensor_desc in SENSOR_VEDO_TYPES | ||||||
|  |                 for device in coordinator.data["alarm_zones"].values() | ||||||
|  |                 if device.index in new_devices | ||||||
|             ) |             ) | ||||||
|             for sensor_desc in SENSOR_VEDO_TYPES |  | ||||||
|         ) |     _check_device() | ||||||
|     async_add_entities(entities) |     config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity): | class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity): | ||||||
|   | |||||||
| @@ -43,11 +43,13 @@ | |||||||
|       "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", |       "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", | ||||||
|       "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", |       "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", | ||||||
|       "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", |       "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", | ||||||
|  |       "invalid_pin": "The provided PIN is invalid. It must be a 4-10 digit number.", | ||||||
|       "unknown": "[%key:common::config_flow::error::unknown%]" |       "unknown": "[%key:common::config_flow::error::unknown%]" | ||||||
|     }, |     }, | ||||||
|     "error": { |     "error": { | ||||||
|       "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", |       "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", | ||||||
|       "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", |       "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", | ||||||
|  |       "invalid_pin": "[%key:component::comelit::config::abort::invalid_pin%]", | ||||||
|       "unknown": "[%key:common::config_flow::error::unknown%]" |       "unknown": "[%key:common::config_flow::error::unknown%]" | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   | |||||||
| @@ -39,6 +39,25 @@ async def async_setup_entry( | |||||||
|     ) |     ) | ||||||
|     async_add_entities(entities) |     async_add_entities(entities) | ||||||
|  |  | ||||||
|  |     known_devices: dict[str, set[int]] = { | ||||||
|  |         dev_type: set() for dev_type in (IRRIGATION, OTHER) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     def _check_device() -> None: | ||||||
|  |         for dev_type in (IRRIGATION, OTHER): | ||||||
|  |             current_devices = set(coordinator.data[dev_type]) | ||||||
|  |             new_devices = current_devices - known_devices[dev_type] | ||||||
|  |             if new_devices: | ||||||
|  |                 known_devices[dev_type].update(new_devices) | ||||||
|  |                 async_add_entities( | ||||||
|  |                     ComelitSwitchEntity(coordinator, device, config_entry.entry_id) | ||||||
|  |                     for device in coordinator.data[dev_type].values() | ||||||
|  |                     if device.index in new_devices | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |     _check_device() | ||||||
|  |     config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity): | class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity): | ||||||
|     """Switch device.""" |     """Switch device.""" | ||||||
|   | |||||||
| @@ -49,7 +49,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) | |||||||
| async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: | ||||||
|     """Set up the config component.""" |     """Set up the config component.""" | ||||||
|     frontend.async_register_built_in_panel( |     frontend.async_register_built_in_panel( | ||||||
|         hass, "config", "config", "hass:cog", require_admin=True |         hass, "config", "config", "mdi:cog", require_admin=True | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     for panel in SECTIONS: |     for panel in SECTIONS: | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ from __future__ import annotations | |||||||
|  |  | ||||||
| from collections.abc import Callable | from collections.abc import Callable | ||||||
| from http import HTTPStatus | from http import HTTPStatus | ||||||
|  | import logging | ||||||
| from typing import Any, NoReturn | from typing import Any, NoReturn | ||||||
|  |  | ||||||
| from aiohttp import web | from aiohttp import web | ||||||
| @@ -23,7 +24,12 @@ from homeassistant.helpers.data_entry_flow import ( | |||||||
|     FlowManagerResourceView, |     FlowManagerResourceView, | ||||||
| ) | ) | ||||||
| from homeassistant.helpers.dispatcher import async_dispatcher_connect | from homeassistant.helpers.dispatcher import async_dispatcher_connect | ||||||
| from homeassistant.helpers.json import json_fragment | from homeassistant.helpers.json import ( | ||||||
|  |     JSON_DUMP, | ||||||
|  |     find_paths_unserializable_data, | ||||||
|  |     json_bytes, | ||||||
|  |     json_fragment, | ||||||
|  | ) | ||||||
| from homeassistant.loader import ( | from homeassistant.loader import ( | ||||||
|     Integration, |     Integration, | ||||||
|     IntegrationNotFound, |     IntegrationNotFound, | ||||||
| @@ -31,6 +37,9 @@ from homeassistant.loader import ( | |||||||
|     async_get_integrations, |     async_get_integrations, | ||||||
|     async_get_loaded_integration, |     async_get_loaded_integration, | ||||||
| ) | ) | ||||||
|  | from homeassistant.util.json import format_unserializable_data | ||||||
|  |  | ||||||
|  | _LOGGER = logging.getLogger(__name__) | ||||||
|  |  | ||||||
|  |  | ||||||
| @callback | @callback | ||||||
| @@ -402,18 +411,40 @@ def config_entries_flow_subscribe( | |||||||
|     connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow( |     connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow( | ||||||
|         async_on_flow_init_remove |         async_on_flow_init_remove | ||||||
|     ) |     ) | ||||||
|     connection.send_message( |     try: | ||||||
|         websocket_api.event_message( |         serialized_flows = [ | ||||||
|             msg["id"], |             json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw}) | ||||||
|             [ |             for flw in hass.config_entries.flow.async_progress() | ||||||
|                 {"type": None, "flow_id": flw["flow_id"], "flow": flw} |             if flw["context"]["source"] | ||||||
|                 for flw in hass.config_entries.flow.async_progress() |             not in ( | ||||||
|                 if flw["context"]["source"] |                 config_entries.SOURCE_RECONFIGURE, | ||||||
|                 not in ( |                 config_entries.SOURCE_USER, | ||||||
|                     config_entries.SOURCE_RECONFIGURE, |             ) | ||||||
|                     config_entries.SOURCE_USER, |         ] | ||||||
|  |     except (ValueError, TypeError): | ||||||
|  |         # If we can't serialize, we'll filter out unserializable flows | ||||||
|  |         serialized_flows = [] | ||||||
|  |         for flw in hass.config_entries.flow.async_progress(): | ||||||
|  |             if flw["context"]["source"] in ( | ||||||
|  |                 config_entries.SOURCE_RECONFIGURE, | ||||||
|  |                 config_entries.SOURCE_USER, | ||||||
|  |             ): | ||||||
|  |                 continue | ||||||
|  |             try: | ||||||
|  |                 serialized_flows.append( | ||||||
|  |                     json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw}) | ||||||
|                 ) |                 ) | ||||||
|             ], |             except (ValueError, TypeError): | ||||||
|  |                 _LOGGER.error( | ||||||
|  |                     "Unable to serialize to JSON. Bad data found at %s", | ||||||
|  |                     format_unserializable_data( | ||||||
|  |                         find_paths_unserializable_data(flw, dump=JSON_DUMP) | ||||||
|  |                     ), | ||||||
|  |                 ) | ||||||
|  |                 continue | ||||||
|  |     connection.send_message( | ||||||
|  |         websocket_api.messages.construct_event_message( | ||||||
|  |             msg["id"], b"".join((b"[", b",".join(serialized_flows), b"]")) | ||||||
|         ) |         ) | ||||||
|     ) |     ) | ||||||
|     connection.send_result(msg["id"]) |     connection.send_result(msg["id"]) | ||||||
|   | |||||||
| @@ -6,5 +6,5 @@ | |||||||
|   "documentation": "https://www.home-assistant.io/integrations/conversation", |   "documentation": "https://www.home-assistant.io/integrations/conversation", | ||||||
|   "integration_type": "entity", |   "integration_type": "entity", | ||||||
|   "quality_scale": "internal", |   "quality_scale": "internal", | ||||||
|   "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.3"] |   "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.10.1"] | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										58
									
								
								homeassistant/components/cync/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								homeassistant/components/cync/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | |||||||
|  | """The Cync integration.""" | ||||||
|  |  | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from pycync import Auth, Cync, User | ||||||
|  | from pycync.exceptions import AuthFailedError, CyncError | ||||||
|  |  | ||||||
|  | from homeassistant.const import CONF_ACCESS_TOKEN, Platform | ||||||
|  | from homeassistant.core import HomeAssistant | ||||||
|  | from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady | ||||||
|  | from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||||||
|  |  | ||||||
|  | from .const import ( | ||||||
|  |     CONF_AUTHORIZE_STRING, | ||||||
|  |     CONF_EXPIRES_AT, | ||||||
|  |     CONF_REFRESH_TOKEN, | ||||||
|  |     CONF_USER_ID, | ||||||
|  | ) | ||||||
|  | from .coordinator import CyncConfigEntry, CyncCoordinator | ||||||
|  |  | ||||||
|  | _PLATFORMS: list[Platform] = [Platform.LIGHT] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool: | ||||||
|  |     """Set up Cync from a config entry.""" | ||||||
|  |     user_info = User( | ||||||
|  |         entry.data[CONF_ACCESS_TOKEN], | ||||||
|  |         entry.data[CONF_REFRESH_TOKEN], | ||||||
|  |         entry.data[CONF_AUTHORIZE_STRING], | ||||||
|  |         entry.data[CONF_USER_ID], | ||||||
|  |         expires_at=entry.data[CONF_EXPIRES_AT], | ||||||
|  |     ) | ||||||
|  |     cync_auth = Auth(async_get_clientsession(hass), user=user_info) | ||||||
|  |  | ||||||
|  |     try: | ||||||
|  |         cync = await Cync.create(cync_auth) | ||||||
|  |     except AuthFailedError as ex: | ||||||
|  |         raise ConfigEntryAuthFailed("User token invalid") from ex | ||||||
|  |     except CyncError as ex: | ||||||
|  |         raise ConfigEntryNotReady("Unable to connect to Cync") from ex | ||||||
|  |  | ||||||
|  |     devices_coordinator = CyncCoordinator(hass, entry, cync) | ||||||
|  |  | ||||||
|  |     cync.set_update_callback(devices_coordinator.on_data_update) | ||||||
|  |  | ||||||
|  |     await devices_coordinator.async_config_entry_first_refresh() | ||||||
|  |     entry.runtime_data = devices_coordinator | ||||||
|  |  | ||||||
|  |     await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) | ||||||
|  |  | ||||||
|  |     return True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def async_unload_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool: | ||||||
|  |     """Unload a config entry.""" | ||||||
|  |     cync = entry.runtime_data.cync | ||||||
|  |     await cync.shut_down() | ||||||
|  |     return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) | ||||||
							
								
								
									
										118
									
								
								homeassistant/components/cync/config_flow.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								homeassistant/components/cync/config_flow.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,118 @@ | |||||||
|  | """Config flow for the Cync integration.""" | ||||||
|  |  | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from pycync import Auth | ||||||
|  | from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError | ||||||
|  | import voluptuous as vol | ||||||
|  |  | ||||||
|  | from homeassistant.config_entries import ConfigFlow, ConfigFlowResult | ||||||
|  | from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD | ||||||
|  | from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||||||
|  |  | ||||||
|  | from .const import ( | ||||||
|  |     CONF_AUTHORIZE_STRING, | ||||||
|  |     CONF_EXPIRES_AT, | ||||||
|  |     CONF_REFRESH_TOKEN, | ||||||
|  |     CONF_TWO_FACTOR_CODE, | ||||||
|  |     CONF_USER_ID, | ||||||
|  |     DOMAIN, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | _LOGGER = logging.getLogger(__name__) | ||||||
|  |  | ||||||
|  | STEP_USER_DATA_SCHEMA = vol.Schema( | ||||||
|  |     { | ||||||
|  |         vol.Required(CONF_EMAIL): str, | ||||||
|  |         vol.Required(CONF_PASSWORD): str, | ||||||
|  |     } | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | STEP_TWO_FACTOR_SCHEMA = vol.Schema({vol.Required(CONF_TWO_FACTOR_CODE): str}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CyncConfigFlow(ConfigFlow, domain=DOMAIN): | ||||||
|  |     """Handle a config flow for Cync.""" | ||||||
|  |  | ||||||
|  |     VERSION = 1 | ||||||
|  |  | ||||||
|  |     cync_auth: Auth | ||||||
|  |  | ||||||
|  |     async def async_step_user( | ||||||
|  |         self, user_input: dict[str, Any] | None = None | ||||||
|  |     ) -> ConfigFlowResult: | ||||||
|  |         """Attempt login with user credentials.""" | ||||||
|  |         errors: dict[str, str] = {} | ||||||
|  |  | ||||||
|  |         if user_input is None: | ||||||
|  |             return self.async_show_form( | ||||||
|  |                 step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         self.cync_auth = Auth( | ||||||
|  |             async_get_clientsession(self.hass), | ||||||
|  |             username=user_input[CONF_EMAIL], | ||||||
|  |             password=user_input[CONF_PASSWORD], | ||||||
|  |         ) | ||||||
|  |         try: | ||||||
|  |             await self.cync_auth.login() | ||||||
|  |         except AuthFailedError: | ||||||
|  |             errors["base"] = "invalid_auth" | ||||||
|  |         except TwoFactorRequiredError: | ||||||
|  |             return await self.async_step_two_factor() | ||||||
|  |         except CyncError: | ||||||
|  |             errors["base"] = "cannot_connect" | ||||||
|  |         except Exception: | ||||||
|  |             _LOGGER.exception("Unexpected exception") | ||||||
|  |             errors["base"] = "unknown" | ||||||
|  |         else: | ||||||
|  |             return await self._create_config_entry(self.cync_auth.username) | ||||||
|  |  | ||||||
|  |         return self.async_show_form( | ||||||
|  |             step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     async def async_step_two_factor( | ||||||
|  |         self, user_input: dict[str, Any] | None = None | ||||||
|  |     ) -> ConfigFlowResult: | ||||||
|  |         """Attempt login with the two factor auth code sent to the user.""" | ||||||
|  |         errors: dict[str, str] = {} | ||||||
|  |  | ||||||
|  |         if user_input is None: | ||||||
|  |             return self.async_show_form( | ||||||
|  |                 step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors | ||||||
|  |             ) | ||||||
|  |         try: | ||||||
|  |             await self.cync_auth.login(user_input[CONF_TWO_FACTOR_CODE]) | ||||||
|  |         except AuthFailedError: | ||||||
|  |             errors["base"] = "invalid_auth" | ||||||
|  |         except CyncError: | ||||||
|  |             errors["base"] = "cannot_connect" | ||||||
|  |         except Exception: | ||||||
|  |             _LOGGER.exception("Unexpected exception") | ||||||
|  |             errors["base"] = "unknown" | ||||||
|  |         else: | ||||||
|  |             return await self._create_config_entry(self.cync_auth.username) | ||||||
|  |  | ||||||
|  |         return self.async_show_form( | ||||||
|  |             step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     async def _create_config_entry(self, user_email: str) -> ConfigFlowResult: | ||||||
|  |         """Create the Cync config entry using input user data.""" | ||||||
|  |  | ||||||
|  |         cync_user = self.cync_auth.user | ||||||
|  |         await self.async_set_unique_id(str(cync_user.user_id)) | ||||||
|  |         self._abort_if_unique_id_configured() | ||||||
|  |  | ||||||
|  |         config = { | ||||||
|  |             CONF_USER_ID: cync_user.user_id, | ||||||
|  |             CONF_AUTHORIZE_STRING: cync_user.authorize, | ||||||
|  |             CONF_EXPIRES_AT: cync_user.expires_at, | ||||||
|  |             CONF_ACCESS_TOKEN: cync_user.access_token, | ||||||
|  |             CONF_REFRESH_TOKEN: cync_user.refresh_token, | ||||||
|  |         } | ||||||
|  |         return self.async_create_entry(title=user_email, data=config) | ||||||
							
								
								
									
										9
									
								
								homeassistant/components/cync/const.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								homeassistant/components/cync/const.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | """Constants for the Cync integration.""" | ||||||
|  |  | ||||||
|  | DOMAIN = "cync" | ||||||
|  |  | ||||||
|  | CONF_TWO_FACTOR_CODE = "two_factor_code" | ||||||
|  | CONF_USER_ID = "user_id" | ||||||
|  | CONF_AUTHORIZE_STRING = "authorize_string" | ||||||
|  | CONF_EXPIRES_AT = "expires_at" | ||||||
|  | CONF_REFRESH_TOKEN = "refresh_token" | ||||||
							
								
								
									
										87
									
								
								homeassistant/components/cync/coordinator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								homeassistant/components/cync/coordinator.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | |||||||
|  | """Coordinator to handle keeping device states up to date.""" | ||||||
|  |  | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from datetime import timedelta | ||||||
|  | import logging | ||||||
|  | import time | ||||||
|  |  | ||||||
|  | from pycync import Cync, CyncDevice, User | ||||||
|  | from pycync.exceptions import AuthFailedError | ||||||
|  |  | ||||||
|  | from homeassistant.config_entries import ConfigEntry | ||||||
|  | from homeassistant.const import CONF_ACCESS_TOKEN | ||||||
|  | from homeassistant.core import HomeAssistant | ||||||
|  | from homeassistant.exceptions import ConfigEntryAuthFailed | ||||||
|  | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator | ||||||
|  |  | ||||||
|  | from .const import CONF_EXPIRES_AT, CONF_REFRESH_TOKEN | ||||||
|  |  | ||||||
|  | _LOGGER = logging.getLogger(__name__) | ||||||
|  |  | ||||||
|  | type CyncConfigEntry = ConfigEntry[CyncCoordinator] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CyncCoordinator(DataUpdateCoordinator[dict[int, CyncDevice]]): | ||||||
|  |     """Coordinator to handle updating Cync device states.""" | ||||||
|  |  | ||||||
|  |     config_entry: CyncConfigEntry | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, hass: HomeAssistant, config_entry: CyncConfigEntry, cync: Cync | ||||||
|  |     ) -> None: | ||||||
|  |         """Initialize the Cync coordinator.""" | ||||||
|  |         super().__init__( | ||||||
|  |             hass, | ||||||
|  |             _LOGGER, | ||||||
|  |             name="Cync Data Coordinator", | ||||||
|  |             config_entry=config_entry, | ||||||
|  |             update_interval=timedelta(seconds=30), | ||||||
|  |             always_update=True, | ||||||
|  |         ) | ||||||
|  |         self.cync = cync | ||||||
|  |  | ||||||
|  |     async def on_data_update(self, data: dict[int, CyncDevice]) -> None: | ||||||
|  |         """Update registered devices with new data.""" | ||||||
|  |         merged_data = self.data | data if self.data else data | ||||||
|  |         self.async_set_updated_data(merged_data) | ||||||
|  |  | ||||||
|  |     async def _async_setup(self) -> None: | ||||||
|  |         """Set up the coordinator with initial device states.""" | ||||||
|  |         logged_in_user = self.cync.get_logged_in_user() | ||||||
|  |         if logged_in_user.access_token != self.config_entry.data[CONF_ACCESS_TOKEN]: | ||||||
|  |             await self._update_config_cync_credentials(logged_in_user) | ||||||
|  |  | ||||||
|  |     async def _async_update_data(self) -> dict[int, CyncDevice]: | ||||||
|  |         """First, refresh the user's auth token if it is set to expire in less than one hour. | ||||||
|  |  | ||||||
|  |         Then, fetch all current device states. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         logged_in_user = self.cync.get_logged_in_user() | ||||||
|  |         if logged_in_user.expires_at - time.time() < 3600: | ||||||
|  |             await self._async_refresh_cync_credentials() | ||||||
|  |  | ||||||
|  |         self.cync.update_device_states() | ||||||
|  |         current_device_states = self.cync.get_devices() | ||||||
|  |  | ||||||
|  |         return {device.device_id: device for device in current_device_states} | ||||||
|  |  | ||||||
|  |     async def _async_refresh_cync_credentials(self) -> None: | ||||||
|  |         """Attempt to refresh the Cync user's authentication token.""" | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             refreshed_user = await self.cync.refresh_credentials() | ||||||
|  |         except AuthFailedError as ex: | ||||||
|  |             raise ConfigEntryAuthFailed("Unable to refresh user token") from ex | ||||||
|  |         else: | ||||||
|  |             await self._update_config_cync_credentials(refreshed_user) | ||||||
|  |  | ||||||
|  |     async def _update_config_cync_credentials(self, user_info: User) -> None: | ||||||
|  |         """Update the config entry with current user info.""" | ||||||
|  |  | ||||||
|  |         new_data = {**self.config_entry.data} | ||||||
|  |         new_data[CONF_ACCESS_TOKEN] = user_info.access_token | ||||||
|  |         new_data[CONF_REFRESH_TOKEN] = user_info.refresh_token | ||||||
|  |         new_data[CONF_EXPIRES_AT] = user_info.expires_at | ||||||
|  |         self.hass.config_entries.async_update_entry(self.config_entry, data=new_data) | ||||||
							
								
								
									
										45
									
								
								homeassistant/components/cync/entity.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								homeassistant/components/cync/entity.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | |||||||
|  | """Setup for a generic entity type for the Cync integration.""" | ||||||
|  |  | ||||||
|  | from pycync.devices import CyncDevice | ||||||
|  |  | ||||||
|  | from homeassistant.helpers.device_registry import DeviceInfo | ||||||
|  | from homeassistant.helpers.update_coordinator import CoordinatorEntity | ||||||
|  |  | ||||||
|  | from .const import DOMAIN | ||||||
|  | from .coordinator import CyncCoordinator | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CyncBaseEntity(CoordinatorEntity[CyncCoordinator]): | ||||||
|  |     """Generic base entity for Cync devices.""" | ||||||
|  |  | ||||||
|  |     _attr_has_entity_name = True | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         device: CyncDevice, | ||||||
|  |         coordinator: CyncCoordinator, | ||||||
|  |         room_name: str | None = None, | ||||||
|  |     ) -> None: | ||||||
|  |         """Pass coordinator to CoordinatorEntity.""" | ||||||
|  |         super().__init__(coordinator) | ||||||
|  |  | ||||||
|  |         self._cync_device_id = device.device_id | ||||||
|  |         self._attr_unique_id = device.unique_id | ||||||
|  |  | ||||||
|  |         self._attr_device_info = DeviceInfo( | ||||||
|  |             identifiers={(DOMAIN, device.unique_id)}, | ||||||
|  |             manufacturer="GE Lighting", | ||||||
|  |             name=device.name, | ||||||
|  |             suggested_area=room_name, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def available(self) -> bool: | ||||||
|  |         """Determines whether this device is currently available.""" | ||||||
|  |  | ||||||
|  |         return ( | ||||||
|  |             super().available | ||||||
|  |             and self.coordinator.data is not None | ||||||
|  |             and self._cync_device_id in self.coordinator.data | ||||||
|  |             and self.coordinator.data[self._cync_device_id].is_online | ||||||
|  |         ) | ||||||
							
								
								
									
										180
									
								
								homeassistant/components/cync/light.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										180
									
								
								homeassistant/components/cync/light.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,180 @@ | |||||||
|  | """Support for Cync light entities.""" | ||||||
|  |  | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from pycync import CyncLight | ||||||
|  | from pycync.devices.capabilities import CyncCapability | ||||||
|  |  | ||||||
|  | from homeassistant.components.light import ( | ||||||
|  |     ATTR_BRIGHTNESS, | ||||||
|  |     ATTR_COLOR_TEMP_KELVIN, | ||||||
|  |     ATTR_RGB_COLOR, | ||||||
|  |     ColorMode, | ||||||
|  |     LightEntity, | ||||||
|  |     filter_supported_color_modes, | ||||||
|  | ) | ||||||
|  | from homeassistant.core import HomeAssistant | ||||||
|  | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback | ||||||
|  | from homeassistant.util.color import value_to_brightness | ||||||
|  | from homeassistant.util.scaling import scale_ranged_value_to_int_range | ||||||
|  |  | ||||||
|  | from .coordinator import CyncConfigEntry, CyncCoordinator | ||||||
|  | from .entity import CyncBaseEntity | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def async_setup_entry( | ||||||
|  |     hass: HomeAssistant, | ||||||
|  |     entry: CyncConfigEntry, | ||||||
|  |     async_add_entities: AddConfigEntryEntitiesCallback, | ||||||
|  | ) -> None: | ||||||
|  |     """Set up Cync lights from a config entry.""" | ||||||
|  |  | ||||||
|  |     coordinator = entry.runtime_data | ||||||
|  |     cync = coordinator.cync | ||||||
|  |  | ||||||
|  |     entities_to_add = [] | ||||||
|  |  | ||||||
|  |     for home in cync.get_homes(): | ||||||
|  |         for room in home.rooms: | ||||||
|  |             room_lights = [ | ||||||
|  |                 CyncLightEntity(device, coordinator, room.name) | ||||||
|  |                 for device in room.devices | ||||||
|  |                 if isinstance(device, CyncLight) | ||||||
|  |             ] | ||||||
|  |             entities_to_add.extend(room_lights) | ||||||
|  |  | ||||||
|  |             group_lights = [ | ||||||
|  |                 CyncLightEntity(device, coordinator, room.name) | ||||||
|  |                 for group in room.groups | ||||||
|  |                 for device in group.devices | ||||||
|  |                 if isinstance(device, CyncLight) | ||||||
|  |             ] | ||||||
|  |             entities_to_add.extend(group_lights) | ||||||
|  |  | ||||||
|  |     async_add_entities(entities_to_add) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CyncLightEntity(CyncBaseEntity, LightEntity): | ||||||
|  |     """Representation of a Cync light.""" | ||||||
|  |  | ||||||
|  |     _attr_color_mode = ColorMode.ONOFF | ||||||
|  |     _attr_min_color_temp_kelvin = 2000 | ||||||
|  |     _attr_max_color_temp_kelvin = 7000 | ||||||
|  |     _attr_translation_key = "light" | ||||||
|  |     _attr_name = None | ||||||
|  |  | ||||||
|  |     BRIGHTNESS_SCALE = (0, 100) | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         device: CyncLight, | ||||||
|  |         coordinator: CyncCoordinator, | ||||||
|  |         room_name: str | None = None, | ||||||
|  |     ) -> None: | ||||||
|  |         """Set up base attributes.""" | ||||||
|  |         super().__init__(device, coordinator, room_name) | ||||||
|  |  | ||||||
|  |         supported_color_modes = {ColorMode.ONOFF} | ||||||
|  |         if device.supports_capability(CyncCapability.CCT_COLOR): | ||||||
|  |             supported_color_modes.add(ColorMode.COLOR_TEMP) | ||||||
|  |         if device.supports_capability(CyncCapability.DIMMING): | ||||||
|  |             supported_color_modes.add(ColorMode.BRIGHTNESS) | ||||||
|  |         if device.supports_capability(CyncCapability.RGB_COLOR): | ||||||
|  |             supported_color_modes.add(ColorMode.RGB) | ||||||
|  |         self._attr_supported_color_modes = filter_supported_color_modes( | ||||||
|  |             supported_color_modes | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_on(self) -> bool | None: | ||||||
|  |         """Return True if the light is on.""" | ||||||
|  |         return self._device.is_on | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def brightness(self) -> int: | ||||||
|  |         """Provide the light's current brightness.""" | ||||||
|  |         return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def color_temp_kelvin(self) -> int: | ||||||
|  |         """Return color temperature in kelvin.""" | ||||||
|  |         return scale_ranged_value_to_int_range( | ||||||
|  |             (1, 100), | ||||||
|  |             (self.min_color_temp_kelvin, self.max_color_temp_kelvin), | ||||||
|  |             self._device.color_temp, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def rgb_color(self) -> tuple[int, int, int]: | ||||||
|  |         """Provide the light's current color in RGB format.""" | ||||||
|  |         return self._device.rgb | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def color_mode(self) -> str | None: | ||||||
|  |         """Return the active color mode.""" | ||||||
|  |  | ||||||
|  |         if ( | ||||||
|  |             self._device.supports_capability(CyncCapability.CCT_COLOR) | ||||||
|  |             and self._device.color_mode > 0 | ||||||
|  |             and self._device.color_mode <= 100 | ||||||
|  |         ): | ||||||
|  |             return ColorMode.COLOR_TEMP | ||||||
|  |         if ( | ||||||
|  |             self._device.supports_capability(CyncCapability.RGB_COLOR) | ||||||
|  |             and self._device.color_mode == 254 | ||||||
|  |         ): | ||||||
|  |             return ColorMode.RGB | ||||||
|  |         if self._device.supports_capability(CyncCapability.DIMMING): | ||||||
|  |             return ColorMode.BRIGHTNESS | ||||||
|  |  | ||||||
|  |         return ColorMode.ONOFF | ||||||
|  |  | ||||||
|  |     async def async_turn_on(self, **kwargs: Any) -> None: | ||||||
|  |         """Process an action on the light.""" | ||||||
|  |         if not kwargs: | ||||||
|  |             await self._device.turn_on() | ||||||
|  |  | ||||||
|  |         elif kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None: | ||||||
|  |             color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) | ||||||
|  |             converted_color_temp = self._normalize_color_temp(color_temp) | ||||||
|  |  | ||||||
|  |             await self._device.set_color_temp(converted_color_temp) | ||||||
|  |         elif kwargs.get(ATTR_RGB_COLOR) is not None: | ||||||
|  |             rgb = kwargs.get(ATTR_RGB_COLOR) | ||||||
|  |  | ||||||
|  |             await self._device.set_rgb(rgb) | ||||||
|  |         elif kwargs.get(ATTR_BRIGHTNESS) is not None: | ||||||
|  |             brightness = kwargs.get(ATTR_BRIGHTNESS) | ||||||
|  |             converted_brightness = self._normalize_brightness(brightness) | ||||||
|  |  | ||||||
|  |             await self._device.set_brightness(converted_brightness) | ||||||
|  |  | ||||||
|  |     async def async_turn_off(self, **kwargs: Any) -> None: | ||||||
|  |         """Turn off the light.""" | ||||||
|  |         await self._device.turn_off() | ||||||
|  |  | ||||||
|  |     def _normalize_brightness(self, brightness: float | None) -> int | None: | ||||||
|  |         """Return calculated brightness value scaled between 0-100.""" | ||||||
|  |         if brightness is not None: | ||||||
|  |             return int((brightness / 255) * 100) | ||||||
|  |  | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     def _normalize_color_temp(self, color_temp_kelvin: float | None) -> int | None: | ||||||
|  |         """Return calculated color temp value scaled between 1-100.""" | ||||||
|  |         if color_temp_kelvin is not None: | ||||||
|  |             kelvin_range = self.max_color_temp_kelvin - self.min_color_temp_kelvin | ||||||
|  |             scaled_kelvin = int( | ||||||
|  |                 ((color_temp_kelvin - self.min_color_temp_kelvin) / kelvin_range) * 100 | ||||||
|  |             ) | ||||||
|  |             if scaled_kelvin == 0: | ||||||
|  |                 scaled_kelvin += 1 | ||||||
|  |  | ||||||
|  |             return scaled_kelvin | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def _device(self) -> CyncLight: | ||||||
|  |         """Fetch the reference to the backing Cync light for this device.""" | ||||||
|  |  | ||||||
|  |         return self.coordinator.data[self._cync_device_id] | ||||||
							
								
								
									
										11
									
								
								homeassistant/components/cync/manifest.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								homeassistant/components/cync/manifest.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | { | ||||||
|  |   "domain": "cync", | ||||||
|  |   "name": "Cync", | ||||||
|  |   "codeowners": ["@Kinachi249"], | ||||||
|  |   "config_flow": true, | ||||||
|  |   "documentation": "https://www.home-assistant.io/integrations/cync", | ||||||
|  |   "integration_type": "hub", | ||||||
|  |   "iot_class": "cloud_push", | ||||||
|  |   "quality_scale": "bronze", | ||||||
|  |   "requirements": ["pycync==0.4.1"] | ||||||
|  | } | ||||||
							
								
								
									
										69
									
								
								homeassistant/components/cync/quality_scale.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								homeassistant/components/cync/quality_scale.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | |||||||
|  | rules: | ||||||
|  |   # Bronze | ||||||
|  |   action-setup: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       This integration does not provide additional actions. | ||||||
|  |   appropriate-polling: done | ||||||
|  |   brands: done | ||||||
|  |   common-modules: done | ||||||
|  |   config-flow-test-coverage: done | ||||||
|  |   config-flow: done | ||||||
|  |   dependency-transparency: done | ||||||
|  |   docs-actions: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       This integration does not provide additional actions. | ||||||
|  |   docs-high-level-description: done | ||||||
|  |   docs-installation-instructions: done | ||||||
|  |   docs-removal-instructions: done | ||||||
|  |   entity-event-setup: done | ||||||
|  |   entity-unique-id: done | ||||||
|  |   has-entity-name: done | ||||||
|  |   runtime-data: done | ||||||
|  |   test-before-configure: done | ||||||
|  |   test-before-setup: done | ||||||
|  |   unique-config-entry: done | ||||||
|  |  | ||||||
|  |   # Silver | ||||||
|  |   action-exceptions: | ||||||
|  |     status: exempt | ||||||
|  |     comment: | | ||||||
|  |       This integration does not provide additional actions. | ||||||
|  |   config-entry-unloading: done | ||||||
|  |   docs-configuration-parameters: todo | ||||||
|  |   docs-installation-parameters: todo | ||||||
|  |   entity-unavailable: todo | ||||||
|  |   integration-owner: done | ||||||
|  |   log-when-unavailable: todo | ||||||
|  |   parallel-updates: todo | ||||||
|  |   reauthentication-flow: todo | ||||||
|  |   test-coverage: todo | ||||||
|  |  | ||||||
|  |   # Gold | ||||||
|  |   devices: done | ||||||
|  |   diagnostics: todo | ||||||
|  |   discovery-update-info: todo | ||||||
|  |   discovery: todo | ||||||
|  |   docs-data-update: todo | ||||||
|  |   docs-examples: todo | ||||||
|  |   docs-known-limitations: done | ||||||
|  |   docs-supported-devices: todo | ||||||
|  |   docs-supported-functions: done | ||||||
|  |   docs-troubleshooting: todo | ||||||
|  |   docs-use-cases: todo | ||||||
|  |   dynamic-devices: todo | ||||||
|  |   entity-category: todo | ||||||
|  |   entity-device-class: todo | ||||||
|  |   entity-disabled-by-default: todo | ||||||
|  |   entity-translations: todo | ||||||
|  |   exception-translations: todo | ||||||
|  |   icon-translations: todo | ||||||
|  |   reconfiguration-flow: todo | ||||||
|  |   repair-issues: todo | ||||||
|  |   stale-devices: todo | ||||||
|  |  | ||||||
|  |   # Platinum | ||||||
|  |   async-dependency: done | ||||||
|  |   inject-websession: done | ||||||
|  |   strict-typing: todo | ||||||
							
								
								
									
										32
									
								
								homeassistant/components/cync/strings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								homeassistant/components/cync/strings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | |||||||
|  | { | ||||||
|  |   "config": { | ||||||
|  |     "step": { | ||||||
|  |       "user": { | ||||||
|  |         "data": { | ||||||
|  |           "email": "[%key:common::config_flow::data::email%]", | ||||||
|  |           "password": "[%key:common::config_flow::data::password%]" | ||||||
|  |         }, | ||||||
|  |         "data_description": { | ||||||
|  |           "email": "Your Cync account's email address", | ||||||
|  |           "password": "Your Cync account's password" | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "two_factor": { | ||||||
|  |         "data": { | ||||||
|  |           "two_factor_code": "Two-factor code" | ||||||
|  |         }, | ||||||
|  |         "data_description": { | ||||||
|  |           "two_factor_code": "The two-factor code sent to your Cync account's email" | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "error": { | ||||||
|  |       "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", | ||||||
|  |       "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", | ||||||
|  |       "unknown": "[%key:common::config_flow::error::unknown%]" | ||||||
|  |     }, | ||||||
|  |     "abort": { | ||||||
|  |       "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession | |||||||
| from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC | from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC | ||||||
| from homeassistant.util.ssl import client_context_no_verify | from homeassistant.util.ssl import client_context_no_verify | ||||||
|  |  | ||||||
| from .const import KEY_MAC, TIMEOUT | from .const import KEY_MAC, TIMEOUT_SEC | ||||||
| from .coordinator import DaikinConfigEntry, DaikinCoordinator | from .coordinator import DaikinConfigEntry, DaikinCoordinator | ||||||
|  |  | ||||||
| _LOGGER = logging.getLogger(__name__) | _LOGGER = logging.getLogger(__name__) | ||||||
| @@ -42,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo | |||||||
|     session = async_get_clientsession(hass) |     session = async_get_clientsession(hass) | ||||||
|     host = conf[CONF_HOST] |     host = conf[CONF_HOST] | ||||||
|     try: |     try: | ||||||
|         async with asyncio.timeout(TIMEOUT): |         async with asyncio.timeout(TIMEOUT_SEC): | ||||||
|             device: Appliance = await DaikinFactory( |             device: Appliance = await DaikinFactory( | ||||||
|                 host, |                 host, | ||||||
|                 session, |                 session, | ||||||
| @@ -53,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo | |||||||
|             ) |             ) | ||||||
|         _LOGGER.debug("Connection to %s successful", host) |         _LOGGER.debug("Connection to %s successful", host) | ||||||
|     except TimeoutError as err: |     except TimeoutError as err: | ||||||
|         _LOGGER.debug("Connection to %s timed out in 60 seconds", host) |         _LOGGER.debug("Connection to %s timed out in %s seconds", host, TIMEOUT_SEC) | ||||||
|         raise ConfigEntryNotReady from err |         raise ConfigEntryNotReady from err | ||||||
|     except ClientConnectionError as err: |     except ClientConnectionError as err: | ||||||
|         _LOGGER.debug("ClientConnectionError to %s", host) |         _LOGGER.debug("ClientConnectionError to %s", host) | ||||||
|   | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user