mirror of
				https://github.com/home-assistant/core.git
				synced 2025-11-04 00:19:31 +00:00 
			
		
		
		
	Compare commits
	
		
			504 Commits
		
	
	
		
			2025.10.2
			...
			input-week
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					39d970347e | ||
| 
						 | 
					9cccc96f63 | ||
| 
						 | 
					a32ada3155 | ||
| 
						 | 
					77f078e57d | ||
| 
						 | 
					8657bfd0bf | ||
| 
						 | 
					fe4eb8766d | ||
| 
						 | 
					2d9f14c401 | ||
| 
						 | 
					7b6ccb07fd | ||
| 
						 | 
					2ba5728060 | ||
| 
						 | 
					b5f163cc85 | ||
| 
						 | 
					65540a3e0b | ||
| 
						 | 
					cbf1b39edb | ||
| 
						 | 
					142daf5e49 | ||
| 
						 | 
					8bd0ff7cca | ||
| 
						 | 
					ac676e12f6 | ||
| 
						 | 
					c0ac3292cd | ||
| 
						 | 
					80fd07c128 | ||
| 
						 | 
					3701d8859a | ||
| 
						 | 
					6dd26bae88 | ||
| 
						 | 
					1a0abe296c | ||
| 
						 | 
					de6c61a4ab | ||
| 
						 | 
					33c677596e | ||
| 
						 | 
					e9b4b8e99b | ||
| 
						 | 
					0525c04c42 | ||
| 
						 | 
					d57b502551 | ||
| 
						 | 
					9fb708baf4 | ||
| 
						 | 
					abdf24b7a0 | ||
| 
						 | 
					29bfbd27bb | ||
| 
						 | 
					224553f8d9 | ||
| 
						 | 
					7c9f6a061f | ||
| 
						 | 
					8e115d4685 | ||
| 
						 | 
					00c189844f | ||
| 
						 | 
					4587c286bb | ||
| 
						 | 
					b46097a7fc | ||
| 
						 | 
					299cb6a2ff | ||
| 
						 | 
					1b7b91b328 | ||
| 
						 | 
					01a1480ebd | ||
| 
						 | 
					26b8abb118 | ||
| 
						 | 
					53d1bbb530 | ||
| 
						 | 
					a3ef55274e | ||
| 
						 | 
					2034915457 | ||
| 
						 | 
					9e46d7964a | ||
| 
						 | 
					f9828a227b | ||
| 
						 | 
					3341fa5f33 | ||
| 
						 | 
					e38ae47e76 | ||
| 
						 | 
					934c0e3c4c | ||
| 
						 | 
					994a6ae7ed | ||
| 
						 | 
					cdbe93c289 | ||
| 
						 | 
					56f90e4d96 | ||
| 
						 | 
					34977abfec | ||
| 
						 | 
					5622103eb1 | ||
| 
						 | 
					b9a1ab4a44 | ||
| 
						 | 
					18997833c4 | ||
| 
						 | 
					f99b194afc | ||
| 
						 | 
					566a347da7 | ||
| 
						 | 
					881306f6a4 | ||
| 
						 | 
					f63504af01 | ||
| 
						 | 
					d140b82a70 | ||
| 
						 | 
					681211b1a5 | ||
| 
						 | 
					6c8b1f3618 | ||
| 
						 | 
					d341065c34 | ||
| 
						 | 
					81b1346080 | ||
| 
						 | 
					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 | ||
| 
						 | 
					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 | ||
| 
						 | 
					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 | ||
| 
						 | 
					52cde48ff0 | ||
| 
						 | 
					bf1da35303 | ||
| 
						 | 
					c1bf11da34 | ||
| 
						 | 
					3c20325b37 | ||
| 
						 | 
					fd8ccb8d8f | ||
| 
						 | 
					d76e947021 | ||
| 
						 | 
					c91ed96543 | ||
| 
						 | 
					b164531ba8 | ||
| 
						 | 
					7c623a8704 | ||
| 
						 | 
					7ae3340336 | ||
| 
						 | 
					653b73c601 | ||
| 
						 | 
					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 | ||
| 
						 | 
					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 | ||
| 
						 | 
					b935231e47 | ||
| 
						 | 
					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 | ||
| 
						 | 
					408df2093a | ||
| 
						 | 
					f32bf0cc3e | ||
| 
						 | 
					dbbe3145b6 | ||
| 
						 | 
					f8bf3ea2ef | ||
| 
						 | 
					053bd31d43 | ||
| 
						 | 
					1aefc3f37a | ||
| 
						 | 
					3de955d9ce | ||
| 
						 | 
					0ff88fd366 | ||
| 
						 | 
					eb84020773 | ||
| 
						 | 
					4bbfea3c7c | ||
| 
						 | 
					63d4fb7558 | ||
| 
						 | 
					953895cd81 | ||
| 
						 | 
					a6c3f4efc0 | ||
| 
						 | 
					11e880d034 | ||
| 
						 | 
					e4d6bdb398 | ||
| 
						 | 
					6ced1783e3 | ||
| 
						 | 
					8051f78d10 | ||
| 
						 | 
					b724176b23 | ||
| 
						 | 
					fdca16ea92 | ||
| 
						 | 
					f8fd8b432a | ||
| 
						 | 
					9148ae70ce | ||
| 
						 | 
					447cb26d28 | ||
| 
						 | 
					2af36465f6 | ||
| 
						 | 
					d5f7265424 | ||
| 
						 | 
					cc16af7f2d | ||
| 
						 | 
					7a4d75bc44 | ||
| 
						 | 
					ec0380fd3b | ||
| 
						 | 
					b17cc71dfb | ||
| 
						 | 
					89b327ed7b | ||
| 
						 | 
					9bf361a1b8 | ||
| 
						 | 
					d11c171c75 | ||
| 
						 | 
					c523c45d17 | ||
| 
						 | 
					c1b9c0e1b6 | ||
| 
						 | 
					487b9ff03e | ||
| 
						 | 
					ec62b0cdfb | ||
| 
						 | 
					6d0470064f | ||
| 
						 | 
					7450b3fd1a | ||
| 
						 | 
					5b70910d77 | ||
| 
						 | 
					52de5ff5ff | ||
| 
						 | 
					c4389a1679 | ||
| 
						 | 
					35faaa6cae | ||
| 
						 | 
					3c0b13975a | ||
| 
						 | 
					bc88696339 | ||
| 
						 | 
					8f99c3f64a | ||
| 
						 | 
					88016d96d4 | ||
| 
						 | 
					47df73b18f | ||
| 
						 | 
					1c12d2b8cd | ||
| 
						 | 
					eb38837a8c | ||
| 
						 | 
					159c7fbfd1 | ||
| 
						 | 
					7ee31f0884 | ||
| 
						 | 
					0c5e12571a | ||
| 
						 | 
					9db973217f | ||
| 
						 | 
					cf1a745283 | ||
| 
						 | 
					834e3f1963 | ||
| 
						 | 
					3f8f7573c9 | ||
| 
						 | 
					0ae272f1f6 | ||
| 
						 | 
					8774295e2e | ||
| 
						 | 
					0c8d2594ef | ||
| 
						 | 
					205bd2676b | ||
| 
						 | 
					25849fd9cc | ||
| 
						 | 
					7d6eac9ff7 | ||
| 
						 | 
					31017ebc98 | ||
| 
						 | 
					724a7b0ecc | ||
| 
						 | 
					91e13d447a | ||
| 
						 | 
					7c8ad9d535 | ||
| 
						 | 
					9cd3ab853d | ||
| 
						 | 
					0b0f8c5829 | ||
| 
						 | 
					ae7bc7fb1b | ||
| 
						 | 
					09750872b5 | ||
| 
						 | 
					076e51017b | ||
| 
						 | 
					95e7b00996 | ||
| 
						 | 
					ddecf1ac21 | 
							
								
								
									
										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
 | 
			
		||||
 | 
			
		||||
      - name: Login to GitHub Container Registry
 | 
			
		||||
        uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
 | 
			
		||||
        uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          username: ${{ github.repository_owner }}
 | 
			
		||||
@@ -257,7 +257,7 @@ jobs:
 | 
			
		||||
          fi
 | 
			
		||||
 | 
			
		||||
      - name: Login to GitHub Container Registry
 | 
			
		||||
        uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
 | 
			
		||||
        uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          username: ${{ github.repository_owner }}
 | 
			
		||||
@@ -332,14 +332,14 @@ jobs:
 | 
			
		||||
 | 
			
		||||
      - name: Login to DockerHub
 | 
			
		||||
        if: matrix.registry == 'docker.io/homeassistant'
 | 
			
		||||
        uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
 | 
			
		||||
        uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          username: ${{ secrets.DOCKERHUB_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKERHUB_TOKEN }}
 | 
			
		||||
 | 
			
		||||
      - name: Login to GitHub Container Registry
 | 
			
		||||
        if: matrix.registry == 'ghcr.io/home-assistant'
 | 
			
		||||
        uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
 | 
			
		||||
        uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          username: ${{ github.repository_owner }}
 | 
			
		||||
@@ -504,7 +504,7 @@ jobs:
 | 
			
		||||
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
 | 
			
		||||
 | 
			
		||||
      - name: Login to GitHub Container Registry
 | 
			
		||||
        uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
 | 
			
		||||
        uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          username: ${{ github.repository_owner }}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										716
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										716
									
								
								.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
 | 
			
		||||
 | 
			
		||||
      - name: Initialize CodeQL
 | 
			
		||||
        uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
 | 
			
		||||
        uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
 | 
			
		||||
        with:
 | 
			
		||||
          languages: python
 | 
			
		||||
 | 
			
		||||
      - name: Perform CodeQL Analysis
 | 
			
		||||
        uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
 | 
			
		||||
        uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
 | 
			
		||||
        with:
 | 
			
		||||
          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 issues (-1)
 | 
			
		||||
      - name: 60 days stale PRs policy
 | 
			
		||||
        uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
 | 
			
		||||
        uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
 | 
			
		||||
        with:
 | 
			
		||||
          repo-token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          days-before-stale: 60
 | 
			
		||||
@@ -57,7 +57,7 @@ jobs:
 | 
			
		||||
      # - No issues marked as no-stale or help-wanted
 | 
			
		||||
      # - No PRs (-1)
 | 
			
		||||
      - name: 90 days stale issues
 | 
			
		||||
        uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
 | 
			
		||||
        uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
 | 
			
		||||
        with:
 | 
			
		||||
          repo-token: ${{ steps.token.outputs.token }}
 | 
			
		||||
          days-before-stale: 90
 | 
			
		||||
@@ -87,7 +87,7 @@ jobs:
 | 
			
		||||
      # - No Issues marked as no-stale or help-wanted
 | 
			
		||||
      # - No PRs (-1)
 | 
			
		||||
      - name: Needs more information stale issues policy
 | 
			
		||||
        uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
 | 
			
		||||
        uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
 | 
			
		||||
        with:
 | 
			
		||||
          repo-token: ${{ steps.token.outputs.token }}
 | 
			
		||||
          only-labels: "needs-more-information"
 | 
			
		||||
 
 | 
			
		||||
@@ -203,6 +203,7 @@ homeassistant.components.feedreader.*
 | 
			
		||||
homeassistant.components.file_upload.*
 | 
			
		||||
homeassistant.components.filesize.*
 | 
			
		||||
homeassistant.components.filter.*
 | 
			
		||||
homeassistant.components.firefly_iii.*
 | 
			
		||||
homeassistant.components.fitbit.*
 | 
			
		||||
homeassistant.components.flexit_bacnet.*
 | 
			
		||||
homeassistant.components.flux_led.*
 | 
			
		||||
@@ -325,6 +326,7 @@ homeassistant.components.london_underground.*
 | 
			
		||||
homeassistant.components.lookin.*
 | 
			
		||||
homeassistant.components.lovelace.*
 | 
			
		||||
homeassistant.components.luftdaten.*
 | 
			
		||||
homeassistant.components.lunatone.*
 | 
			
		||||
homeassistant.components.madvr.*
 | 
			
		||||
homeassistant.components.manual.*
 | 
			
		||||
homeassistant.components.mastodon.*
 | 
			
		||||
@@ -553,6 +555,7 @@ homeassistant.components.vacuum.*
 | 
			
		||||
homeassistant.components.vallox.*
 | 
			
		||||
homeassistant.components.valve.*
 | 
			
		||||
homeassistant.components.velbus.*
 | 
			
		||||
homeassistant.components.vivotek.*
 | 
			
		||||
homeassistant.components.vlc_telnet.*
 | 
			
		||||
homeassistant.components.vodafone_station.*
 | 
			
		||||
homeassistant.components.volvo.*
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								CODEOWNERS
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										20
									
								
								CODEOWNERS
									
									
									
										generated
									
									
									
								
							@@ -492,6 +492,8 @@ build.json @home-assistant/supervisor
 | 
			
		||||
/tests/components/filesize/ @gjohansson-ST
 | 
			
		||||
/homeassistant/components/filter/ @dgomes
 | 
			
		||||
/tests/components/filter/ @dgomes
 | 
			
		||||
/homeassistant/components/firefly_iii/ @erwindouna
 | 
			
		||||
/tests/components/firefly_iii/ @erwindouna
 | 
			
		||||
/homeassistant/components/fireservicerota/ @cyberjunky
 | 
			
		||||
/tests/components/fireservicerota/ @cyberjunky
 | 
			
		||||
/homeassistant/components/firmata/ @DaAwesomeP
 | 
			
		||||
@@ -751,6 +753,8 @@ build.json @home-assistant/supervisor
 | 
			
		||||
/tests/components/input_select/ @home-assistant/core
 | 
			
		||||
/homeassistant/components/input_text/ @home-assistant/core
 | 
			
		||||
/tests/components/input_text/ @home-assistant/core
 | 
			
		||||
/homeassistant/components/input_weekday/ @home-assistant/core
 | 
			
		||||
/tests/components/input_weekday/ @home-assistant/core
 | 
			
		||||
/homeassistant/components/insteon/ @teharris1
 | 
			
		||||
/tests/components/insteon/ @teharris1
 | 
			
		||||
/homeassistant/components/integration/ @dgomes
 | 
			
		||||
@@ -760,8 +764,8 @@ build.json @home-assistant/supervisor
 | 
			
		||||
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
 | 
			
		||||
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
 | 
			
		||||
/homeassistant/components/intesishome/ @jnimmo
 | 
			
		||||
/homeassistant/components/iometer/ @jukrebs
 | 
			
		||||
/tests/components/iometer/ @jukrebs
 | 
			
		||||
/homeassistant/components/iometer/ @MaestroOnICe
 | 
			
		||||
/tests/components/iometer/ @MaestroOnICe
 | 
			
		||||
/homeassistant/components/ios/ @robbiet480
 | 
			
		||||
/tests/components/ios/ @robbiet480
 | 
			
		||||
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
 | 
			
		||||
@@ -908,6 +912,8 @@ build.json @home-assistant/supervisor
 | 
			
		||||
/homeassistant/components/luci/ @mzdrale
 | 
			
		||||
/homeassistant/components/luftdaten/ @fabaff @frenck
 | 
			
		||||
/tests/components/luftdaten/ @fabaff @frenck
 | 
			
		||||
/homeassistant/components/lunatone/ @MoonDevLT
 | 
			
		||||
/tests/components/lunatone/ @MoonDevLT
 | 
			
		||||
/homeassistant/components/lupusec/ @majuss @suaveolent
 | 
			
		||||
/tests/components/lupusec/ @majuss @suaveolent
 | 
			
		||||
/homeassistant/components/lutron/ @cdheiser @wilburCForce
 | 
			
		||||
@@ -953,6 +959,8 @@ build.json @home-assistant/supervisor
 | 
			
		||||
/tests/components/met_eireann/ @DylanGore
 | 
			
		||||
/homeassistant/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/meteoclimatic/ @adrianmo
 | 
			
		||||
/tests/components/meteoclimatic/ @adrianmo
 | 
			
		||||
@@ -1059,6 +1067,8 @@ build.json @home-assistant/supervisor
 | 
			
		||||
/homeassistant/components/nilu/ @hfurubotten
 | 
			
		||||
/homeassistant/components/nina/ @DeerMaximum
 | 
			
		||||
/tests/components/nina/ @DeerMaximum
 | 
			
		||||
/homeassistant/components/nintendo_parental/ @pantherale0
 | 
			
		||||
/tests/components/nintendo_parental/ @pantherale0
 | 
			
		||||
/homeassistant/components/nissan_leaf/ @filcole
 | 
			
		||||
/homeassistant/components/noaa_tides/ @jdelaney72
 | 
			
		||||
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
 | 
			
		||||
@@ -1190,8 +1200,6 @@ build.json @home-assistant/supervisor
 | 
			
		||||
/tests/components/plex/ @jjlawren
 | 
			
		||||
/homeassistant/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
 | 
			
		||||
/tests/components/point/ @fredrike
 | 
			
		||||
/homeassistant/components/pooldose/ @lmaertin
 | 
			
		||||
@@ -1407,8 +1415,8 @@ build.json @home-assistant/supervisor
 | 
			
		||||
/tests/components/sfr_box/ @epenet
 | 
			
		||||
/homeassistant/components/sftp_storage/ @maretodoric
 | 
			
		||||
/tests/components/sftp_storage/ @maretodoric
 | 
			
		||||
/homeassistant/components/sharkiq/ @JeffResc @funkybunch
 | 
			
		||||
/tests/components/sharkiq/ @JeffResc @funkybunch
 | 
			
		||||
/homeassistant/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre
 | 
			
		||||
/tests/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre
 | 
			
		||||
/homeassistant/components/shell_command/ @home-assistant/core
 | 
			
		||||
/tests/components/shell_command/ @home-assistant/core
 | 
			
		||||
/homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco
 | 
			
		||||
 
 | 
			
		||||
@@ -231,6 +231,7 @@ DEFAULT_INTEGRATIONS = {
 | 
			
		||||
    "input_datetime",
 | 
			
		||||
    "input_number",
 | 
			
		||||
    "input_select",
 | 
			
		||||
    "input_weekday",
 | 
			
		||||
    "input_text",
 | 
			
		||||
    "schedule",
 | 
			
		||||
    "timer",
 | 
			
		||||
@@ -616,34 +617,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:
 | 
			
		||||
        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:
 | 
			
		||||
        err_log_path = os.path.abspath(log_file)
 | 
			
		||||
 | 
			
		||||
    err_path_exists = os.path.isfile(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)
 | 
			
		||||
    ):
 | 
			
		||||
    if err_log_path:
 | 
			
		||||
        err_handler = await hass.async_add_executor_job(
 | 
			
		||||
            _create_log_file, err_log_path, log_rotate_days
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
 | 
			
		||||
 | 
			
		||||
        logger = logging.getLogger()
 | 
			
		||||
        logger.addHandler(err_handler)
 | 
			
		||||
        logger.setLevel(logging.INFO if verbose else logging.WARNING)
 | 
			
		||||
 | 
			
		||||
        # Save the log file location for access by other components.
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "domain": "ibm",
 | 
			
		||||
  "name": "IBM",
 | 
			
		||||
  "integrations": ["watson_iot", "watson_tts"]
 | 
			
		||||
}
 | 
			
		||||
@@ -12,11 +12,13 @@ from homeassistant.components.bluetooth import async_get_scanner
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
from homeassistant.const import CONF_ADDRESS
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers.debounce import Debouncer
 | 
			
		||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
 | 
			
		||||
 | 
			
		||||
from .const import CONF_IS_NEW_STYLE_SCALE
 | 
			
		||||
 | 
			
		||||
SCAN_INTERVAL = timedelta(seconds=15)
 | 
			
		||||
UPDATE_DEBOUNCE_TIME = 0.2
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -38,11 +40,19 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]):
 | 
			
		||||
            config_entry=entry,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        debouncer = Debouncer(
 | 
			
		||||
            hass=hass,
 | 
			
		||||
            logger=_LOGGER,
 | 
			
		||||
            cooldown=UPDATE_DEBOUNCE_TIME,
 | 
			
		||||
            immediate=True,
 | 
			
		||||
            function=self.async_update_listeners,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self._scale = AcaiaScale(
 | 
			
		||||
            address_or_ble_device=entry.data[CONF_ADDRESS],
 | 
			
		||||
            name=entry.title,
 | 
			
		||||
            is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
 | 
			
		||||
            notify_callback=self.async_update_listeners,
 | 
			
		||||
            notify_callback=debouncer.async_schedule_call,
 | 
			
		||||
            scanner=async_get_scanner(hass),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,7 @@
 | 
			
		||||
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from collections.abc import Mapping
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
@@ -14,7 +15,7 @@ from airos.exceptions import (
 | 
			
		||||
)
 | 
			
		||||
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,
 | 
			
		||||
@@ -24,6 +25,11 @@ from homeassistant.const import (
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.data_entry_flow import section
 | 
			
		||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
 | 
			
		||||
from homeassistant.helpers.selector import (
 | 
			
		||||
    TextSelector,
 | 
			
		||||
    TextSelectorConfig,
 | 
			
		||||
    TextSelectorType,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
 | 
			
		||||
from .coordinator import AirOS8
 | 
			
		||||
@@ -54,50 +60,107 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
    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(
 | 
			
		||||
        self,
 | 
			
		||||
        user_input: dict[str, Any] | None = None,
 | 
			
		||||
        self, user_input: dict[str, Any] | None = None
 | 
			
		||||
    ) -> ConfigFlowResult:
 | 
			
		||||
        """Handle the initial step."""
 | 
			
		||||
        errors: dict[str, str] = {}
 | 
			
		||||
        """Handle the manual input of host and credentials."""
 | 
			
		||||
        self.errors = {}
 | 
			
		||||
        if user_input is not None:
 | 
			
		||||
            # 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=user_input[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            airos_device = AirOS8(
 | 
			
		||||
                host=user_input[CONF_HOST],
 | 
			
		||||
                username=user_input[CONF_USERNAME],
 | 
			
		||||
                password=user_input[CONF_PASSWORD],
 | 
			
		||||
                session=session,
 | 
			
		||||
                use_ssl=user_input[SECTION_ADVANCED_SETTINGS][CONF_SSL],
 | 
			
		||||
            )
 | 
			
		||||
            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()
 | 
			
		||||
            validated_info = await self._validate_and_get_device_info(user_input)
 | 
			
		||||
            if validated_info:
 | 
			
		||||
                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(
 | 
			
		||||
            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,
 | 
			
		||||
        )
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@ from airos.exceptions import (
 | 
			
		||||
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.exceptions import ConfigEntryError
 | 
			
		||||
from homeassistant.exceptions import ConfigEntryAuthFailed
 | 
			
		||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
 | 
			
		||||
 | 
			
		||||
from .const import DOMAIN, SCAN_INTERVAL
 | 
			
		||||
@@ -47,9 +47,9 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
 | 
			
		||||
        try:
 | 
			
		||||
            await self.airos_device.login()
 | 
			
		||||
            return await self.airos_device.status()
 | 
			
		||||
        except (AirOSConnectionAuthenticationError,) as err:
 | 
			
		||||
        except AirOSConnectionAuthenticationError as err:
 | 
			
		||||
            _LOGGER.exception("Error authenticating with airOS device")
 | 
			
		||||
            raise ConfigEntryError(
 | 
			
		||||
            raise ConfigEntryAuthFailed(
 | 
			
		||||
                translation_domain=DOMAIN, translation_key="invalid_auth"
 | 
			
		||||
            ) from err
 | 
			
		||||
        except (
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,14 @@
 | 
			
		||||
  "config": {
 | 
			
		||||
    "flow_title": "Ubiquiti airOS device",
 | 
			
		||||
    "step": {
 | 
			
		||||
      "reauth_confirm": {
 | 
			
		||||
        "data": {
 | 
			
		||||
          "password": "[%key:common::config_flow::data::password%]"
 | 
			
		||||
        },
 | 
			
		||||
        "data_description": {
 | 
			
		||||
          "password": "[%key:component::airos::config::step::user::data_description::password%]"
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "user": {
 | 
			
		||||
        "data": {
 | 
			
		||||
          "host": "[%key:common::config_flow::data::host%]",
 | 
			
		||||
@@ -34,7 +42,9 @@
 | 
			
		||||
      "unknown": "[%key:common::config_flow::error::unknown%]"
 | 
			
		||||
    },
 | 
			
		||||
    "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": {
 | 
			
		||||
 
 | 
			
		||||
@@ -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):
 | 
			
		||||
    """Handle a config flow for Airthings."""
 | 
			
		||||
@@ -37,11 +41,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
            return self.async_show_form(
 | 
			
		||||
                step_id="user",
 | 
			
		||||
                data_schema=STEP_USER_DATA_SCHEMA,
 | 
			
		||||
                description_placeholders={
 | 
			
		||||
                    "url": (
 | 
			
		||||
                        "https://dashboard.airthings.com/integrations/api-integration"
 | 
			
		||||
                    ),
 | 
			
		||||
                },
 | 
			
		||||
                description_placeholders=URL_API_INTEGRATION,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        errors = {}
 | 
			
		||||
@@ -65,5 +65,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
            return self.async_create_entry(title="Airthings", data=user_input)
 | 
			
		||||
 | 
			
		||||
        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": {
 | 
			
		||||
        "data": {
 | 
			
		||||
          "id": "ID",
 | 
			
		||||
          "secret": "Secret",
 | 
			
		||||
          "description": "Login at {url} to find your credentials"
 | 
			
		||||
        }
 | 
			
		||||
          "secret": "Secret"
 | 
			
		||||
        },
 | 
			
		||||
        "description": "Log in at {url} to find your credentials"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "error": {
 | 
			
		||||
 
 | 
			
		||||
@@ -6,8 +6,13 @@ import dataclasses
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice
 | 
			
		||||
from airthings_ble import (
 | 
			
		||||
    AirthingsBluetoothDeviceData,
 | 
			
		||||
    AirthingsDevice,
 | 
			
		||||
    UnsupportedDeviceError,
 | 
			
		||||
)
 | 
			
		||||
from bleak import BleakError
 | 
			
		||||
from habluetooth import BluetoothServiceInfoBleak
 | 
			
		||||
import voluptuous as vol
 | 
			
		||||
 | 
			
		||||
from homeassistant.components import bluetooth
 | 
			
		||||
@@ -27,6 +32,7 @@ SERVICE_UUIDS = [
 | 
			
		||||
    "b42e4a8e-ade7-11e4-89d3-123b93f75cba",
 | 
			
		||||
    "b42e1c08-ade7-11e4-89d3-123b93f75cba",
 | 
			
		||||
    "b42e3882-ade7-11e4-89d3-123b93f75cba",
 | 
			
		||||
    "b42e90a2-ade7-11e4-89d3-123b93f75cba",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -37,6 +43,7 @@ class Discovery:
 | 
			
		||||
    name: str
 | 
			
		||||
    discovery_info: BluetoothServiceInfo
 | 
			
		||||
    device: AirthingsDevice
 | 
			
		||||
    data: AirthingsBluetoothDeviceData
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_name(device: AirthingsDevice) -> str:
 | 
			
		||||
@@ -44,7 +51,7 @@ def get_name(device: AirthingsDevice) -> str:
 | 
			
		||||
 | 
			
		||||
    name = device.friendly_name()
 | 
			
		||||
    if identifier := device.identifier:
 | 
			
		||||
        name += f" ({identifier})"
 | 
			
		||||
        name += f" ({device.model.value}{identifier})"
 | 
			
		||||
    return name
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -62,8 +69,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
        self._discovered_device: Discovery | None = None
 | 
			
		||||
        self._discovered_devices: dict[str, Discovery] = {}
 | 
			
		||||
 | 
			
		||||
    async def _get_device_data(
 | 
			
		||||
        self, discovery_info: BluetoothServiceInfo
 | 
			
		||||
    async def _get_device(
 | 
			
		||||
        self, data: AirthingsBluetoothDeviceData, discovery_info: BluetoothServiceInfo
 | 
			
		||||
    ) -> AirthingsDevice:
 | 
			
		||||
        ble_device = bluetooth.async_ble_device_from_address(
 | 
			
		||||
            self.hass, discovery_info.address
 | 
			
		||||
@@ -72,10 +79,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
            _LOGGER.debug("no ble_device in _get_device_data")
 | 
			
		||||
            raise AirthingsDeviceUpdateError("No ble_device")
 | 
			
		||||
 | 
			
		||||
        airthings = AirthingsBluetoothDeviceData(_LOGGER)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            data = await airthings.update_device(ble_device)
 | 
			
		||||
            device = await data.update_device(ble_device)
 | 
			
		||||
        except BleakError as err:
 | 
			
		||||
            _LOGGER.error(
 | 
			
		||||
                "Error connecting to and getting data from %s: %s",
 | 
			
		||||
@@ -83,12 +88,15 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
                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:
 | 
			
		||||
            _LOGGER.error(
 | 
			
		||||
                "Unknown error occurred from %s: %s", discovery_info.address, err
 | 
			
		||||
            )
 | 
			
		||||
            raise
 | 
			
		||||
        return data
 | 
			
		||||
        return device
 | 
			
		||||
 | 
			
		||||
    async def async_step_bluetooth(
 | 
			
		||||
        self, discovery_info: BluetoothServiceInfo
 | 
			
		||||
@@ -98,17 +106,21 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
        await self.async_set_unique_id(discovery_info.address)
 | 
			
		||||
        self._abort_if_unique_id_configured()
 | 
			
		||||
 | 
			
		||||
        data = AirthingsBluetoothDeviceData(logger=_LOGGER)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            device = await self._get_device_data(discovery_info)
 | 
			
		||||
            device = await self._get_device(data=data, discovery_info=discovery_info)
 | 
			
		||||
        except AirthingsDeviceUpdateError:
 | 
			
		||||
            return self.async_abort(reason="cannot_connect")
 | 
			
		||||
        except UnsupportedDeviceError:
 | 
			
		||||
            return self.async_abort(reason="unsupported_device")
 | 
			
		||||
        except Exception:
 | 
			
		||||
            _LOGGER.exception("Unknown error occurred")
 | 
			
		||||
            return self.async_abort(reason="unknown")
 | 
			
		||||
 | 
			
		||||
        name = get_name(device)
 | 
			
		||||
        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()
 | 
			
		||||
 | 
			
		||||
@@ -117,6 +129,12 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
    ) -> ConfigFlowResult:
 | 
			
		||||
        """Confirm discovery."""
 | 
			
		||||
        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(
 | 
			
		||||
                title=self.context["title_placeholders"]["name"], data={}
 | 
			
		||||
            )
 | 
			
		||||
@@ -137,6 +155,9 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
            self._abort_if_unique_id_configured()
 | 
			
		||||
            discovery = self._discovered_devices[address]
 | 
			
		||||
 | 
			
		||||
            if discovery.device.firmware.need_firmware_upgrade:
 | 
			
		||||
                return self.async_abort(reason="firmware_upgrade_required")
 | 
			
		||||
 | 
			
		||||
            self.context["title_placeholders"] = {
 | 
			
		||||
                "name": discovery.name,
 | 
			
		||||
            }
 | 
			
		||||
@@ -146,32 +167,53 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
            return self.async_create_entry(title=discovery.name, data={})
 | 
			
		||||
 | 
			
		||||
        current_addresses = self._async_current_ids(include_ignore=False)
 | 
			
		||||
        devices: list[BluetoothServiceInfoBleak] = []
 | 
			
		||||
        for discovery_info in async_discovered_service_info(self.hass):
 | 
			
		||||
            address = discovery_info.address
 | 
			
		||||
            if address in current_addresses or address in self._discovered_devices:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            if MFCT_ID not in discovery_info.manufacturer_data:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            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
 | 
			
		||||
            devices.append(discovery_info)
 | 
			
		||||
 | 
			
		||||
        for discovery_info in devices:
 | 
			
		||||
            address = discovery_info.address
 | 
			
		||||
            data = AirthingsBluetoothDeviceData(logger=_LOGGER)
 | 
			
		||||
            try:
 | 
			
		||||
                device = await self._get_device_data(discovery_info)
 | 
			
		||||
                device = await self._get_device(data, discovery_info)
 | 
			
		||||
            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:
 | 
			
		||||
                _LOGGER.exception("Unknown error occurred")
 | 
			
		||||
                return self.async_abort(reason="unknown")
 | 
			
		||||
            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:
 | 
			
		||||
            return self.async_abort(reason="no_devices_found")
 | 
			
		||||
 | 
			
		||||
        titles = {
 | 
			
		||||
            address: discovery.device.name
 | 
			
		||||
            address: get_name(discovery.device)
 | 
			
		||||
            for (address, discovery) in self._discovered_devices.items()
 | 
			
		||||
        }
 | 
			
		||||
        return self.async_show_form(
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,10 @@
 | 
			
		||||
    {
 | 
			
		||||
      "manufacturer_id": 820,
 | 
			
		||||
      "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "manufacturer_id": 820,
 | 
			
		||||
      "service_uuid": "b42e90a2-ade7-11e4-89d3-123b93f75cba"
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "codeowners": ["@vincegio", "@LaStrada"],
 | 
			
		||||
@@ -24,5 +28,5 @@
 | 
			
		||||
  "dependencies": ["bluetooth_adapters"],
 | 
			
		||||
  "documentation": "https://www.home-assistant.io/integrations/airthings_ble",
 | 
			
		||||
  "iot_class": "local_polling",
 | 
			
		||||
  "requirements": ["airthings-ble==0.9.2"]
 | 
			
		||||
  "requirements": ["airthings-ble==1.1.1"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -16,10 +16,12 @@ from homeassistant.components.sensor import (
 | 
			
		||||
from homeassistant.const import (
 | 
			
		||||
    CONCENTRATION_PARTS_PER_BILLION,
 | 
			
		||||
    CONCENTRATION_PARTS_PER_MILLION,
 | 
			
		||||
    LIGHT_LUX,
 | 
			
		||||
    PERCENTAGE,
 | 
			
		||||
    EntityCategory,
 | 
			
		||||
    Platform,
 | 
			
		||||
    UnitOfPressure,
 | 
			
		||||
    UnitOfSoundPressure,
 | 
			
		||||
    UnitOfTemperature,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.core import HomeAssistant, callback
 | 
			
		||||
@@ -112,8 +114,25 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
 | 
			
		||||
        state_class=SensorStateClass.MEASUREMENT,
 | 
			
		||||
        suggested_display_precision=0,
 | 
			
		||||
    ),
 | 
			
		||||
    "lux": SensorEntityDescription(
 | 
			
		||||
        key="lux",
 | 
			
		||||
        device_class=SensorDeviceClass.ILLUMINANCE,
 | 
			
		||||
        native_unit_of_measurement=LIGHT_LUX,
 | 
			
		||||
        state_class=SensorStateClass.MEASUREMENT,
 | 
			
		||||
        suggested_display_precision=0,
 | 
			
		||||
    ),
 | 
			
		||||
    "noise": SensorEntityDescription(
 | 
			
		||||
        key="noise",
 | 
			
		||||
        translation_key="ambient_noise",
 | 
			
		||||
        device_class=SensorDeviceClass.SOUND_PRESSURE,
 | 
			
		||||
        native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
 | 
			
		||||
        state_class=SensorStateClass.MEASUREMENT,
 | 
			
		||||
        suggested_display_precision=0,
 | 
			
		||||
    ),
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
PARALLEL_UPDATES = 0
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@callback
 | 
			
		||||
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,9 @@
 | 
			
		||||
        "description": "[%key:component::bluetooth::config::step::user::description%]",
 | 
			
		||||
        "data": {
 | 
			
		||||
          "address": "[%key:common::config_flow::data::device%]"
 | 
			
		||||
        },
 | 
			
		||||
        "data_description": {
 | 
			
		||||
          "address": "The Airthings devices discovered via Bluetooth."
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "bluetooth_confirm": {
 | 
			
		||||
@@ -17,6 +20,8 @@
 | 
			
		||||
      "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
 | 
			
		||||
      "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
 | 
			
		||||
      "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%]"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
@@ -36,6 +41,9 @@
 | 
			
		||||
      },
 | 
			
		||||
      "illuminance": {
 | 
			
		||||
        "name": "[%key:component::sensor::entity_component::illuminance::name%]"
 | 
			
		||||
      },
 | 
			
		||||
      "ambient_noise": {
 | 
			
		||||
        "name": "Ambient noise"
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -8,5 +8,5 @@
 | 
			
		||||
  "iot_class": "cloud_polling",
 | 
			
		||||
  "loggers": ["aioamazondevices"],
 | 
			
		||||
  "quality_scale": "platinum",
 | 
			
		||||
  "requirements": ["aioamazondevices==6.4.0"]
 | 
			
		||||
  "requirements": ["aioamazondevices==6.2.9"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -65,6 +65,31 @@ SENSOR_DESCRIPTIONS = [
 | 
			
		||||
        suggested_display_precision=2,
 | 
			
		||||
        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(
 | 
			
		||||
        device_class=SensorDeviceClass.PRESSURE,
 | 
			
		||||
        key="BMP_pressure",
 | 
			
		||||
 
 | 
			
		||||
@@ -629,7 +629,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:  # noqa: C901
 | 
			
		||||
 | 
			
		||||
            devices_info.append(
 | 
			
		||||
                {
 | 
			
		||||
                    "entities": [],
 | 
			
		||||
                    "entry_type": device_entry.entry_type,
 | 
			
		||||
                    "has_configuration_url": device_entry.configuration_url is not None,
 | 
			
		||||
                    "hw_version": device_entry.hw_version,
 | 
			
		||||
@@ -638,6 +637,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:  # noqa: C901
 | 
			
		||||
                    "model_id": device_entry.model_id,
 | 
			
		||||
                    "sw_version": device_entry.sw_version,
 | 
			
		||||
                    "via_device": device_entry.via_device_id,
 | 
			
		||||
                    "entities": [],
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -19,9 +19,8 @@ CONF_THINKING_BUDGET = "thinking_budget"
 | 
			
		||||
RECOMMENDED_THINKING_BUDGET = 0
 | 
			
		||||
MIN_THINKING_BUDGET = 1024
 | 
			
		||||
 | 
			
		||||
THINKING_MODELS = [
 | 
			
		||||
    "claude-3-7-sonnet",
 | 
			
		||||
    "claude-sonnet-4-0",
 | 
			
		||||
    "claude-opus-4-0",
 | 
			
		||||
    "claude-opus-4-1",
 | 
			
		||||
NON_THINKING_MODELS = [
 | 
			
		||||
    "claude-3-5",  # Both sonnet and haiku
 | 
			
		||||
    "claude-3-opus",
 | 
			
		||||
    "claude-3-haiku",
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
@@ -51,11 +51,11 @@ from .const import (
 | 
			
		||||
    DOMAIN,
 | 
			
		||||
    LOGGER,
 | 
			
		||||
    MIN_THINKING_BUDGET,
 | 
			
		||||
    NON_THINKING_MODELS,
 | 
			
		||||
    RECOMMENDED_CHAT_MODEL,
 | 
			
		||||
    RECOMMENDED_MAX_TOKENS,
 | 
			
		||||
    RECOMMENDED_TEMPERATURE,
 | 
			
		||||
    RECOMMENDED_THINKING_BUDGET,
 | 
			
		||||
    THINKING_MODELS,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
# Max number of back and forth with the LLM to generate a response
 | 
			
		||||
@@ -364,7 +364,7 @@ class AnthropicBaseLLMEntity(Entity):
 | 
			
		||||
        if tools:
 | 
			
		||||
            model_args["tools"] = tools
 | 
			
		||||
        if (
 | 
			
		||||
            model.startswith(tuple(THINKING_MODELS))
 | 
			
		||||
            not model.startswith(tuple(NON_THINKING_MODELS))
 | 
			
		||||
            and thinking_budget >= MIN_THINKING_BUDGET
 | 
			
		||||
        ):
 | 
			
		||||
            model_args["thinking"] = ThinkingConfigEnabledParam(
 | 
			
		||||
 
 | 
			
		||||
@@ -8,5 +8,5 @@
 | 
			
		||||
  "documentation": "https://www.home-assistant.io/integrations/anthropic",
 | 
			
		||||
  "integration_type": "service",
 | 
			
		||||
  "iot_class": "cloud_polling",
 | 
			
		||||
  "requirements": ["anthropic==0.62.0"]
 | 
			
		||||
  "requirements": ["anthropic==0.69.0"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,9 +2,7 @@
 | 
			
		||||
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from typing import Any, TypeVar
 | 
			
		||||
 | 
			
		||||
T = TypeVar("T", dict[str, Any], list[Any], None)
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
TRANSLATION_MAP = {
 | 
			
		||||
    "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")}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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."""
 | 
			
		||||
 | 
			
		||||
    if raw is None:
 | 
			
		||||
 
 | 
			
		||||
@@ -26,9 +26,6 @@ async def async_setup_entry(
 | 
			
		||||
 | 
			
		||||
    if CONF_HOST in config_entry.data:
 | 
			
		||||
        coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session)
 | 
			
		||||
        config_entry.async_on_unload(
 | 
			
		||||
            config_entry.add_update_listener(_async_update_listener)
 | 
			
		||||
        )
 | 
			
		||||
    else:
 | 
			
		||||
        coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session)
 | 
			
		||||
 | 
			
		||||
@@ -36,6 +33,11 @@ async def async_setup_entry(
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
    return True
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant, callback
 | 
			
		||||
from homeassistant.exceptions import HomeAssistantError
 | 
			
		||||
from homeassistant.helpers import frame
 | 
			
		||||
from homeassistant.util import slugify
 | 
			
		||||
from homeassistant.util.async_iterator import AsyncIteratorReader, AsyncIteratorWriter
 | 
			
		||||
 | 
			
		||||
from . import util
 | 
			
		||||
from .agent import BackupAgent
 | 
			
		||||
@@ -144,7 +145,7 @@ class DownloadBackupView(HomeAssistantView):
 | 
			
		||||
                return Response(status=HTTPStatus.NOT_FOUND)
 | 
			
		||||
        else:
 | 
			
		||||
            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()
 | 
			
		||||
 | 
			
		||||
@@ -152,7 +153,7 @@ class DownloadBackupView(HomeAssistantView):
 | 
			
		||||
            """Call by the worker thread when it's done."""
 | 
			
		||||
            hass.loop.call_soon_threadsafe(worker_done_event.set)
 | 
			
		||||
 | 
			
		||||
        stream = util.AsyncIteratorWriter(hass)
 | 
			
		||||
        stream = AsyncIteratorWriter(hass.loop)
 | 
			
		||||
        worker = threading.Thread(
 | 
			
		||||
            target=util.decrypt_backup,
 | 
			
		||||
            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.util import dt as dt_util, json as json_util
 | 
			
		||||
from homeassistant.util.async_iterator import AsyncIteratorReader
 | 
			
		||||
 | 
			
		||||
from . import util as backup_util
 | 
			
		||||
from .agent import (
 | 
			
		||||
@@ -72,7 +73,6 @@ from .models import (
 | 
			
		||||
)
 | 
			
		||||
from .store import BackupStore
 | 
			
		||||
from .util import (
 | 
			
		||||
    AsyncIteratorReader,
 | 
			
		||||
    DecryptedBackupStreamer,
 | 
			
		||||
    EncryptedBackupStreamer,
 | 
			
		||||
    make_backup_dir,
 | 
			
		||||
@@ -1525,7 +1525,7 @@ class BackupManager:
 | 
			
		||||
            reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb")
 | 
			
		||||
        else:
 | 
			
		||||
            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:
 | 
			
		||||
            await self.hass.async_add_executor_job(
 | 
			
		||||
                validate_password_stream, reader, password
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,6 @@ from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import asyncio
 | 
			
		||||
from collections.abc import AsyncIterator, Callable, Coroutine
 | 
			
		||||
from concurrent.futures import CancelledError, Future
 | 
			
		||||
import copy
 | 
			
		||||
from dataclasses import dataclass, replace
 | 
			
		||||
from io import BytesIO
 | 
			
		||||
@@ -14,7 +13,7 @@ from pathlib import Path, PurePath
 | 
			
		||||
from queue import SimpleQueue
 | 
			
		||||
import tarfile
 | 
			
		||||
import threading
 | 
			
		||||
from typing import IO, Any, Self, cast
 | 
			
		||||
from typing import IO, Any, cast
 | 
			
		||||
 | 
			
		||||
import aiohttp
 | 
			
		||||
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.exceptions import HomeAssistantError
 | 
			
		||||
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 .const import BUF_SIZE, LOGGER
 | 
			
		||||
@@ -59,12 +63,6 @@ class BackupEmpty(DecryptError):
 | 
			
		||||
    _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:
 | 
			
		||||
    """Create a backup directory if it does not exist."""
 | 
			
		||||
    path.mkdir(exist_ok=True)
 | 
			
		||||
@@ -166,106 +164,6 @@ def validate_password(path: Path, password: str | None) -> bool:
 | 
			
		||||
    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(
 | 
			
		||||
    input_stream: IO[bytes],
 | 
			
		||||
    password: str | None,
 | 
			
		||||
@@ -342,7 +240,7 @@ def decrypt_backup(
 | 
			
		||||
        finally:
 | 
			
		||||
            # Write an empty chunk to signal the end of the stream
 | 
			
		||||
            output_stream.write(b"")
 | 
			
		||||
    except AbortCipher:
 | 
			
		||||
    except Abort:
 | 
			
		||||
        LOGGER.debug("Cipher operation aborted")
 | 
			
		||||
    finally:
 | 
			
		||||
        on_done(error)
 | 
			
		||||
@@ -430,7 +328,7 @@ def encrypt_backup(
 | 
			
		||||
        finally:
 | 
			
		||||
            # Write an empty chunk to signal the end of the stream
 | 
			
		||||
            output_stream.write(b"")
 | 
			
		||||
    except AbortCipher:
 | 
			
		||||
    except Abort:
 | 
			
		||||
        LOGGER.debug("Cipher operation aborted")
 | 
			
		||||
    finally:
 | 
			
		||||
        on_done(error)
 | 
			
		||||
@@ -557,8 +455,8 @@ class _CipherBackupStreamer:
 | 
			
		||||
            self._hass.loop.call_soon_threadsafe(worker_status.done.set)
 | 
			
		||||
 | 
			
		||||
        stream = await self._open_stream()
 | 
			
		||||
        reader = AsyncIteratorReader(self._hass, stream)
 | 
			
		||||
        writer = AsyncIteratorWriter(self._hass)
 | 
			
		||||
        reader = AsyncIteratorReader(self._hass.loop, stream)
 | 
			
		||||
        writer = AsyncIteratorWriter(self._hass.loop)
 | 
			
		||||
        worker = threading.Thread(
 | 
			
		||||
            target=self._cipher_func,
 | 
			
		||||
            args=[
 | 
			
		||||
 
 | 
			
		||||
@@ -73,11 +73,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry)
 | 
			
		||||
    # Add the websocket and API 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)
 | 
			
		||||
 | 
			
		||||
    # 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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -125,7 +125,8 @@ async def async_setup_entry(
 | 
			
		||||
    async_add_entities(
 | 
			
		||||
        new_entities=[
 | 
			
		||||
            BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client)
 | 
			
		||||
        ]
 | 
			
		||||
        ],
 | 
			
		||||
        update_before_add=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Register actions.
 | 
			
		||||
@@ -266,34 +267,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
 | 
			
		||||
            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()
 | 
			
		||||
 | 
			
		||||
        # 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.
 | 
			
		||||
        await self._async_update_sources()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,16 +3,12 @@ beolink_allstandby:
 | 
			
		||||
    entity:
 | 
			
		||||
      integration: bang_olufsen
 | 
			
		||||
      domain: media_player
 | 
			
		||||
    device:
 | 
			
		||||
      integration: bang_olufsen
 | 
			
		||||
 | 
			
		||||
beolink_expand:
 | 
			
		||||
  target:
 | 
			
		||||
    entity:
 | 
			
		||||
      integration: bang_olufsen
 | 
			
		||||
      domain: media_player
 | 
			
		||||
    device:
 | 
			
		||||
      integration: bang_olufsen
 | 
			
		||||
  fields:
 | 
			
		||||
    all_discovered:
 | 
			
		||||
      required: false
 | 
			
		||||
@@ -37,8 +33,6 @@ beolink_join:
 | 
			
		||||
    entity:
 | 
			
		||||
      integration: bang_olufsen
 | 
			
		||||
      domain: media_player
 | 
			
		||||
    device:
 | 
			
		||||
      integration: bang_olufsen
 | 
			
		||||
  fields:
 | 
			
		||||
    jid_options:
 | 
			
		||||
      collapsed: false
 | 
			
		||||
@@ -71,16 +65,12 @@ beolink_leave:
 | 
			
		||||
    entity:
 | 
			
		||||
      integration: bang_olufsen
 | 
			
		||||
      domain: media_player
 | 
			
		||||
    device:
 | 
			
		||||
      integration: bang_olufsen
 | 
			
		||||
 | 
			
		||||
beolink_unexpand:
 | 
			
		||||
  target:
 | 
			
		||||
    entity:
 | 
			
		||||
      integration: bang_olufsen
 | 
			
		||||
      domain: media_player
 | 
			
		||||
    device:
 | 
			
		||||
      integration: bang_olufsen
 | 
			
		||||
  fields:
 | 
			
		||||
    jid_options:
 | 
			
		||||
      collapsed: false
 | 
			
		||||
 
 | 
			
		||||
@@ -19,8 +19,8 @@
 | 
			
		||||
    "bleak-retry-connector==4.4.3",
 | 
			
		||||
    "bluetooth-adapters==2.1.0",
 | 
			
		||||
    "bluetooth-auto-recovery==1.5.3",
 | 
			
		||||
    "bluetooth-data-tools==1.28.2",
 | 
			
		||||
    "dbus-fast==2.44.3",
 | 
			
		||||
    "habluetooth==5.6.4"
 | 
			
		||||
    "bluetooth-data-tools==1.28.3",
 | 
			
		||||
    "dbus-fast==2.44.5",
 | 
			
		||||
    "habluetooth==5.7.0"
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@
 | 
			
		||||
  "integration_type": "device",
 | 
			
		||||
  "iot_class": "local_polling",
 | 
			
		||||
  "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
 | 
			
		||||
  "requirements": ["brother==5.1.1"],
 | 
			
		||||
  "requirements": ["brother==5.1.0"],
 | 
			
		||||
  "zeroconf": [
 | 
			
		||||
    {
 | 
			
		||||
      "type": "_printer._tcp.local.",
 | 
			
		||||
 
 | 
			
		||||
@@ -315,9 +315,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
 | 
			
		||||
    hass.http.register_view(CalendarListView(component))
 | 
			
		||||
    hass.http.register_view(CalendarEventView(component))
 | 
			
		||||
 | 
			
		||||
    frontend.async_register_built_in_panel(
 | 
			
		||||
        hass, "calendar", "calendar", "hass:calendar"
 | 
			
		||||
    )
 | 
			
		||||
    frontend.async_register_built_in_panel(hass, "calendar", "calendar", "mdi:calendar")
 | 
			
		||||
 | 
			
		||||
    websocket_api.async_register_command(hass, handle_calendar_event_create)
 | 
			
		||||
    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.exceptions import HomeAssistantError
 | 
			
		||||
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_component import EntityComponent
 | 
			
		||||
from homeassistant.helpers.event import async_track_time_interval
 | 
			
		||||
@@ -118,12 +112,6 @@ ATTR_FILENAME: Final = "filename"
 | 
			
		||||
ATTR_MEDIA_PLAYER: Final = "media_player"
 | 
			
		||||
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):
 | 
			
		||||
    """Supported features of the camera entity."""
 | 
			
		||||
@@ -1117,11 +1105,3 @@ async def async_handle_record_service(
 | 
			
		||||
        duration=service_call.data[CONF_DURATION],
 | 
			
		||||
        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_ALEXA,
 | 
			
		||||
    CONF_ALIASES,
 | 
			
		||||
    CONF_CLOUDHOOK_SERVER,
 | 
			
		||||
    CONF_COGNITO_CLIENT_ID,
 | 
			
		||||
    CONF_ENTITY_CONFIG,
 | 
			
		||||
    CONF_FILTER,
 | 
			
		||||
@@ -130,7 +129,6 @@ CONFIG_SCHEMA = vol.Schema(
 | 
			
		||||
                vol.Optional(CONF_ACCOUNT_LINK_SERVER): str,
 | 
			
		||||
                vol.Optional(CONF_ACCOUNTS_SERVER): str,
 | 
			
		||||
                vol.Optional(CONF_ACME_SERVER): str,
 | 
			
		||||
                vol.Optional(CONF_CLOUDHOOK_SERVER): str,
 | 
			
		||||
                vol.Optional(CONF_RELAYER_SERVER): str,
 | 
			
		||||
                vol.Optional(CONF_REMOTESTATE_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_ACCOUNTS_SERVER = "accounts_server"
 | 
			
		||||
CONF_ACME_SERVER = "acme_server"
 | 
			
		||||
CONF_CLOUDHOOK_SERVER = "cloudhook_server"
 | 
			
		||||
CONF_RELAYER_SERVER = "relayer_server"
 | 
			
		||||
CONF_REMOTESTATE_SERVER = "remotestate_server"
 | 
			
		||||
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,6 @@
 | 
			
		||||
  "integration_type": "system",
 | 
			
		||||
  "iot_class": "cloud_push",
 | 
			
		||||
  "loggers": ["acme", "hass_nabucasa", "snitun"],
 | 
			
		||||
  "requirements": ["hass-nabucasa==1.1.1"],
 | 
			
		||||
  "requirements": ["hass-nabucasa==1.2.0"],
 | 
			
		||||
  "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
 | 
			
		||||
@@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
 | 
			
		||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
 | 
			
		||||
 | 
			
		||||
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
 | 
			
		||||
from .utils import DeviceType, new_device_listener
 | 
			
		||||
 | 
			
		||||
# Coordinator is used to centralize the data updates
 | 
			
		||||
PARALLEL_UPDATES = 0
 | 
			
		||||
@@ -29,23 +30,19 @@ async def async_setup_entry(
 | 
			
		||||
 | 
			
		||||
    coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
 | 
			
		||||
 | 
			
		||||
    known_devices: set[int] = set()
 | 
			
		||||
    def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
 | 
			
		||||
        """Add entities for new monitors."""
 | 
			
		||||
        entities = [
 | 
			
		||||
            ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
 | 
			
		||||
            for device in coordinator.data["alarm_zones"].values()
 | 
			
		||||
            if device in new_devices
 | 
			
		||||
        ]
 | 
			
		||||
        if entities:
 | 
			
		||||
            async_add_entities(entities)
 | 
			
		||||
 | 
			
		||||
    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))
 | 
			
		||||
    config_entry.async_on_unload(
 | 
			
		||||
        new_device_listener(coordinator, _add_new_entities, "alarm_zones")
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ComelitVedoBinarySensorEntity(
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
 | 
			
		||||
 | 
			
		||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
 | 
			
		||||
from .entity import ComelitBridgeBaseEntity
 | 
			
		||||
from .utils import bridge_api_call
 | 
			
		||||
from .utils import DeviceType, bridge_api_call, new_device_listener
 | 
			
		||||
 | 
			
		||||
# Coordinator is used to centralize the data updates
 | 
			
		||||
PARALLEL_UPDATES = 0
 | 
			
		||||
@@ -36,21 +36,19 @@ async def async_setup_entry(
 | 
			
		||||
 | 
			
		||||
    coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
 | 
			
		||||
 | 
			
		||||
    known_devices: set[int] = set()
 | 
			
		||||
    def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
 | 
			
		||||
        """Add entities for new monitors."""
 | 
			
		||||
        entities = [
 | 
			
		||||
            ComelitCoverEntity(coordinator, device, config_entry.entry_id)
 | 
			
		||||
            for device in coordinator.data[dev_type].values()
 | 
			
		||||
            if device in new_devices
 | 
			
		||||
        ]
 | 
			
		||||
        if entities:
 | 
			
		||||
            async_add_entities(entities)
 | 
			
		||||
 | 
			
		||||
    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))
 | 
			
		||||
    config_entry.async_on_unload(
 | 
			
		||||
        new_device_listener(coordinator, _add_new_entities, COVER)
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
 | 
			
		||||
 | 
			
		||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
 | 
			
		||||
from .entity import ComelitBridgeBaseEntity
 | 
			
		||||
from .utils import bridge_api_call
 | 
			
		||||
from .utils import DeviceType, bridge_api_call, new_device_listener
 | 
			
		||||
 | 
			
		||||
# Coordinator is used to centralize the data updates
 | 
			
		||||
PARALLEL_UPDATES = 0
 | 
			
		||||
@@ -27,21 +27,19 @@ async def async_setup_entry(
 | 
			
		||||
 | 
			
		||||
    coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
 | 
			
		||||
 | 
			
		||||
    known_devices: set[int] = set()
 | 
			
		||||
    def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
 | 
			
		||||
        """Add entities for new monitors."""
 | 
			
		||||
        entities = [
 | 
			
		||||
            ComelitLightEntity(coordinator, device, config_entry.entry_id)
 | 
			
		||||
            for device in coordinator.data[dev_type].values()
 | 
			
		||||
            if device in new_devices
 | 
			
		||||
        ]
 | 
			
		||||
        if entities:
 | 
			
		||||
            async_add_entities(entities)
 | 
			
		||||
 | 
			
		||||
    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))
 | 
			
		||||
    config_entry.async_on_unload(
 | 
			
		||||
        new_device_listener(coordinator, _add_new_entities, LIGHT)
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
 | 
			
		||||
 | 
			
		||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
 | 
			
		||||
from .entity import ComelitBridgeBaseEntity
 | 
			
		||||
from .utils import DeviceType, new_device_listener
 | 
			
		||||
 | 
			
		||||
# Coordinator is used to centralize the data updates
 | 
			
		||||
PARALLEL_UPDATES = 0
 | 
			
		||||
@@ -65,24 +66,22 @@ async def async_setup_bridge_entry(
 | 
			
		||||
 | 
			
		||||
    coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
 | 
			
		||||
 | 
			
		||||
    known_devices: set[int] = set()
 | 
			
		||||
 | 
			
		||||
    def _check_device() -> None:
 | 
			
		||||
        current_devices = set(coordinator.data[OTHER])
 | 
			
		||||
        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
 | 
			
		||||
    def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
 | 
			
		||||
        """Add entities for new monitors."""
 | 
			
		||||
        entities = [
 | 
			
		||||
            ComelitBridgeSensorEntity(
 | 
			
		||||
                coordinator, device, config_entry.entry_id, sensor_desc
 | 
			
		||||
            )
 | 
			
		||||
            for sensor_desc in SENSOR_BRIDGE_TYPES
 | 
			
		||||
            for device in coordinator.data[dev_type].values()
 | 
			
		||||
            if device in new_devices
 | 
			
		||||
        ]
 | 
			
		||||
        if entities:
 | 
			
		||||
            async_add_entities(entities)
 | 
			
		||||
 | 
			
		||||
    _check_device()
 | 
			
		||||
    config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
 | 
			
		||||
    config_entry.async_on_unload(
 | 
			
		||||
        new_device_listener(coordinator, _add_new_entities, OTHER)
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_setup_vedo_entry(
 | 
			
		||||
@@ -94,24 +93,22 @@ async def async_setup_vedo_entry(
 | 
			
		||||
 | 
			
		||||
    coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
 | 
			
		||||
 | 
			
		||||
    known_devices: set[int] = set()
 | 
			
		||||
 | 
			
		||||
    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(
 | 
			
		||||
                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
 | 
			
		||||
    def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
 | 
			
		||||
        """Add entities for new monitors."""
 | 
			
		||||
        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 in new_devices
 | 
			
		||||
        ]
 | 
			
		||||
        if entities:
 | 
			
		||||
            async_add_entities(entities)
 | 
			
		||||
 | 
			
		||||
    _check_device()
 | 
			
		||||
    config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
 | 
			
		||||
    config_entry.async_on_unload(
 | 
			
		||||
        new_device_listener(coordinator, _add_new_entities, "alarm_zones")
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
 | 
			
		||||
 | 
			
		||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
 | 
			
		||||
from .entity import ComelitBridgeBaseEntity
 | 
			
		||||
from .utils import bridge_api_call
 | 
			
		||||
from .utils import DeviceType, bridge_api_call, new_device_listener
 | 
			
		||||
 | 
			
		||||
# Coordinator is used to centralize the data updates
 | 
			
		||||
PARALLEL_UPDATES = 0
 | 
			
		||||
@@ -28,35 +28,20 @@ async def async_setup_entry(
 | 
			
		||||
 | 
			
		||||
    coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
 | 
			
		||||
 | 
			
		||||
    entities: list[ComelitSwitchEntity] = []
 | 
			
		||||
    entities.extend(
 | 
			
		||||
        ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
 | 
			
		||||
        for device in coordinator.data[IRRIGATION].values()
 | 
			
		||||
    )
 | 
			
		||||
    entities.extend(
 | 
			
		||||
        ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
 | 
			
		||||
        for device in coordinator.data[OTHER].values()
 | 
			
		||||
    )
 | 
			
		||||
    async_add_entities(entities)
 | 
			
		||||
    def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
 | 
			
		||||
        """Add entities for new monitors."""
 | 
			
		||||
        entities = [
 | 
			
		||||
            ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
 | 
			
		||||
            for device in coordinator.data[dev_type].values()
 | 
			
		||||
            if device in new_devices
 | 
			
		||||
        ]
 | 
			
		||||
        if 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))
 | 
			
		||||
    for dev_type in (IRRIGATION, OTHER):
 | 
			
		||||
        config_entry.async_on_unload(
 | 
			
		||||
            new_device_listener(coordinator, _add_new_entities, dev_type)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,11 @@ from collections.abc import Awaitable, Callable, Coroutine
 | 
			
		||||
from functools import wraps
 | 
			
		||||
from typing import Any, Concatenate
 | 
			
		||||
 | 
			
		||||
from aiocomelit import ComelitSerialBridgeObject
 | 
			
		||||
from aiocomelit.api import (
 | 
			
		||||
    ComelitSerialBridgeObject,
 | 
			
		||||
    ComelitVedoAreaObject,
 | 
			
		||||
    ComelitVedoZoneObject,
 | 
			
		||||
)
 | 
			
		||||
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
 | 
			
		||||
from aiohttp import ClientSession, CookieJar
 | 
			
		||||
 | 
			
		||||
@@ -19,8 +23,11 @@ from homeassistant.helpers import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
from .const import _LOGGER, DOMAIN
 | 
			
		||||
from .coordinator import ComelitBaseCoordinator
 | 
			
		||||
from .entity import ComelitBridgeBaseEntity
 | 
			
		||||
 | 
			
		||||
DeviceType = ComelitSerialBridgeObject | ComelitVedoAreaObject | ComelitVedoZoneObject
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_client_session(hass: HomeAssistant) -> ClientSession:
 | 
			
		||||
    """Return a new aiohttp session."""
 | 
			
		||||
@@ -113,3 +120,41 @@ def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P](
 | 
			
		||||
            self.coordinator.config_entry.async_start_reauth(self.hass)
 | 
			
		||||
 | 
			
		||||
    return cmd_wrapper
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def new_device_listener(
 | 
			
		||||
    coordinator: ComelitBaseCoordinator,
 | 
			
		||||
    new_devices_callback: Callable[
 | 
			
		||||
        [
 | 
			
		||||
            list[
 | 
			
		||||
                ComelitSerialBridgeObject
 | 
			
		||||
                | ComelitVedoAreaObject
 | 
			
		||||
                | ComelitVedoZoneObject
 | 
			
		||||
            ],
 | 
			
		||||
            str,
 | 
			
		||||
        ],
 | 
			
		||||
        None,
 | 
			
		||||
    ],
 | 
			
		||||
    data_type: str,
 | 
			
		||||
) -> Callable[[], None]:
 | 
			
		||||
    """Subscribe to coordinator updates to check for new devices."""
 | 
			
		||||
    known_devices: set[int] = set()
 | 
			
		||||
 | 
			
		||||
    def _check_devices() -> None:
 | 
			
		||||
        """Check for new devices and call callback with any new monitors."""
 | 
			
		||||
        if not coordinator.data:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        new_devices: list[DeviceType] = []
 | 
			
		||||
        for _id in coordinator.data[data_type]:
 | 
			
		||||
            if _id not in known_devices:
 | 
			
		||||
                known_devices.add(_id)
 | 
			
		||||
                new_devices.append(coordinator.data[data_type][_id])
 | 
			
		||||
 | 
			
		||||
        if new_devices:
 | 
			
		||||
            new_devices_callback(new_devices, data_type)
 | 
			
		||||
 | 
			
		||||
    # Check for devices immediately
 | 
			
		||||
    _check_devices()
 | 
			
		||||
 | 
			
		||||
    return coordinator.async_add_listener(_check_devices)
 | 
			
		||||
 
 | 
			
		||||
@@ -49,7 +49,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
 | 
			
		||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
 | 
			
		||||
    """Set up the config component."""
 | 
			
		||||
    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:
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
from http import HTTPStatus
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Any, NoReturn
 | 
			
		||||
 | 
			
		||||
from aiohttp import web
 | 
			
		||||
@@ -23,7 +24,12 @@ from homeassistant.helpers.data_entry_flow import (
 | 
			
		||||
    FlowManagerResourceView,
 | 
			
		||||
)
 | 
			
		||||
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 (
 | 
			
		||||
    Integration,
 | 
			
		||||
    IntegrationNotFound,
 | 
			
		||||
@@ -31,6 +37,9 @@ from homeassistant.loader import (
 | 
			
		||||
    async_get_integrations,
 | 
			
		||||
    async_get_loaded_integration,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.util.json import format_unserializable_data
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@callback
 | 
			
		||||
@@ -402,18 +411,40 @@ def config_entries_flow_subscribe(
 | 
			
		||||
    connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow(
 | 
			
		||||
        async_on_flow_init_remove
 | 
			
		||||
    )
 | 
			
		||||
    connection.send_message(
 | 
			
		||||
        websocket_api.event_message(
 | 
			
		||||
            msg["id"],
 | 
			
		||||
            [
 | 
			
		||||
                {"type": None, "flow_id": flw["flow_id"], "flow": flw}
 | 
			
		||||
                for flw in hass.config_entries.flow.async_progress()
 | 
			
		||||
                if flw["context"]["source"]
 | 
			
		||||
                not in (
 | 
			
		||||
                    config_entries.SOURCE_RECONFIGURE,
 | 
			
		||||
                    config_entries.SOURCE_USER,
 | 
			
		||||
    try:
 | 
			
		||||
        serialized_flows = [
 | 
			
		||||
            json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw})
 | 
			
		||||
            for flw in hass.config_entries.flow.async_progress()
 | 
			
		||||
            if flw["context"]["source"]
 | 
			
		||||
            not in (
 | 
			
		||||
                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"])
 | 
			
		||||
 
 | 
			
		||||
@@ -514,7 +514,7 @@ class ChatLog:
 | 
			
		||||
        """Set the LLM system prompt."""
 | 
			
		||||
        llm_api: llm.APIInstance | None = None
 | 
			
		||||
 | 
			
		||||
        if not user_llm_hass_api:
 | 
			
		||||
        if user_llm_hass_api is None:
 | 
			
		||||
            pass
 | 
			
		||||
        elif isinstance(user_llm_hass_api, llm.API):
 | 
			
		||||
            llm_api = await user_llm_hass_api.async_get_api_instance(llm_context)
 | 
			
		||||
 
 | 
			
		||||
@@ -38,22 +38,30 @@ from home_assistant_intents import (
 | 
			
		||||
    ErrorKey,
 | 
			
		||||
    FuzzyConfig,
 | 
			
		||||
    FuzzyLanguageResponses,
 | 
			
		||||
    LanguageScores,
 | 
			
		||||
    get_fuzzy_config,
 | 
			
		||||
    get_fuzzy_language,
 | 
			
		||||
    get_intents,
 | 
			
		||||
    get_language_scores,
 | 
			
		||||
    get_languages,
 | 
			
		||||
)
 | 
			
		||||
import yaml
 | 
			
		||||
 | 
			
		||||
from homeassistant import core
 | 
			
		||||
from homeassistant.components.homeassistant.exposed_entities import (
 | 
			
		||||
    async_listen_entity_updates,
 | 
			
		||||
    async_should_expose,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
 | 
			
		||||
from homeassistant.core import Event, callback
 | 
			
		||||
from homeassistant.core import (
 | 
			
		||||
    Event,
 | 
			
		||||
    EventStateChangedData,
 | 
			
		||||
    HomeAssistant,
 | 
			
		||||
    State,
 | 
			
		||||
    callback,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.helpers import (
 | 
			
		||||
    area_registry as ar,
 | 
			
		||||
    config_validation as cv,
 | 
			
		||||
    device_registry as dr,
 | 
			
		||||
    entity_registry as er,
 | 
			
		||||
    floor_registry as fr,
 | 
			
		||||
@@ -192,7 +200,7 @@ class IntentCache:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_setup_default_agent(
 | 
			
		||||
    hass: core.HomeAssistant,
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    entity_component: EntityComponent[ConversationEntity],
 | 
			
		||||
    config_intents: dict[str, Any],
 | 
			
		||||
) -> None:
 | 
			
		||||
@@ -201,15 +209,13 @@ async def async_setup_default_agent(
 | 
			
		||||
    await entity_component.async_add_entities([agent])
 | 
			
		||||
    await get_agent_manager(hass).async_setup_default_agent(agent)
 | 
			
		||||
 | 
			
		||||
    @core.callback
 | 
			
		||||
    def async_entity_state_listener(
 | 
			
		||||
        event: core.Event[core.EventStateChangedData],
 | 
			
		||||
    ) -> None:
 | 
			
		||||
    @callback
 | 
			
		||||
    def async_entity_state_listener(event: Event[EventStateChangedData]) -> None:
 | 
			
		||||
        """Set expose flag on new entities."""
 | 
			
		||||
        async_should_expose(hass, DOMAIN, event.data["entity_id"])
 | 
			
		||||
 | 
			
		||||
    @core.callback
 | 
			
		||||
    def async_hass_started(hass: core.HomeAssistant) -> None:
 | 
			
		||||
    @callback
 | 
			
		||||
    def async_hass_started(hass: HomeAssistant) -> None:
 | 
			
		||||
        """Set expose flag on all entities."""
 | 
			
		||||
        for state in hass.states.async_all():
 | 
			
		||||
            async_should_expose(hass, DOMAIN, state.entity_id)
 | 
			
		||||
@@ -224,9 +230,7 @@ class DefaultAgent(ConversationEntity):
 | 
			
		||||
    _attr_name = "Home Assistant"
 | 
			
		||||
    _attr_supported_features = ConversationEntityFeature.CONTROL
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self, hass: core.HomeAssistant, config_intents: dict[str, Any]
 | 
			
		||||
    ) -> None:
 | 
			
		||||
    def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
 | 
			
		||||
        """Initialize the default agent."""
 | 
			
		||||
        self.hass = hass
 | 
			
		||||
        self._lang_intents: dict[str, LanguageIntents | object] = {}
 | 
			
		||||
@@ -259,7 +263,7 @@ class DefaultAgent(ConversationEntity):
 | 
			
		||||
        """Return a list of supported languages."""
 | 
			
		||||
        return get_languages()
 | 
			
		||||
 | 
			
		||||
    @core.callback
 | 
			
		||||
    @callback
 | 
			
		||||
    def _filter_entity_registry_changes(
 | 
			
		||||
        self, event_data: er.EventEntityRegistryUpdatedData
 | 
			
		||||
    ) -> bool:
 | 
			
		||||
@@ -268,12 +272,12 @@ class DefaultAgent(ConversationEntity):
 | 
			
		||||
            field in event_data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @core.callback
 | 
			
		||||
    def _filter_state_changes(self, event_data: core.EventStateChangedData) -> bool:
 | 
			
		||||
    @callback
 | 
			
		||||
    def _filter_state_changes(self, event_data: EventStateChangedData) -> bool:
 | 
			
		||||
        """Filter state changed events."""
 | 
			
		||||
        return not event_data["old_state"] or not event_data["new_state"]
 | 
			
		||||
 | 
			
		||||
    @core.callback
 | 
			
		||||
    @callback
 | 
			
		||||
    def _listen_clear_slot_list(self) -> None:
 | 
			
		||||
        """Listen for changes that can invalidate slot list."""
 | 
			
		||||
        assert self._unsub_clear_slot_list is None
 | 
			
		||||
@@ -342,6 +346,81 @@ class DefaultAgent(ConversationEntity):
 | 
			
		||||
 | 
			
		||||
        return result
 | 
			
		||||
 | 
			
		||||
    async def async_debug_recognize(
 | 
			
		||||
        self, user_input: ConversationInput
 | 
			
		||||
    ) -> dict[str, Any] | None:
 | 
			
		||||
        """Debug recognize from user input."""
 | 
			
		||||
        result_dict: dict[str, Any] | None = None
 | 
			
		||||
 | 
			
		||||
        if trigger_result := await self.async_recognize_sentence_trigger(user_input):
 | 
			
		||||
            result_dict = {
 | 
			
		||||
                # Matched a user-defined sentence trigger.
 | 
			
		||||
                # We can't provide the response here without executing the
 | 
			
		||||
                # trigger.
 | 
			
		||||
                "match": True,
 | 
			
		||||
                "source": "trigger",
 | 
			
		||||
                "sentence_template": trigger_result.sentence_template or "",
 | 
			
		||||
            }
 | 
			
		||||
        elif intent_result := await self.async_recognize_intent(user_input):
 | 
			
		||||
            successful_match = not intent_result.unmatched_entities
 | 
			
		||||
            result_dict = {
 | 
			
		||||
                # Name of the matching intent (or the closest)
 | 
			
		||||
                "intent": {
 | 
			
		||||
                    "name": intent_result.intent.name,
 | 
			
		||||
                },
 | 
			
		||||
                # Slot values that would be received by the intent
 | 
			
		||||
                "slots": {  # direct access to values
 | 
			
		||||
                    entity_key: entity.text or entity.value
 | 
			
		||||
                    for entity_key, entity in intent_result.entities.items()
 | 
			
		||||
                },
 | 
			
		||||
                # Extra slot details, such as the originally matched text
 | 
			
		||||
                "details": {
 | 
			
		||||
                    entity_key: {
 | 
			
		||||
                        "name": entity.name,
 | 
			
		||||
                        "value": entity.value,
 | 
			
		||||
                        "text": entity.text,
 | 
			
		||||
                    }
 | 
			
		||||
                    for entity_key, entity in intent_result.entities.items()
 | 
			
		||||
                },
 | 
			
		||||
                # Entities/areas/etc. that would be targeted
 | 
			
		||||
                "targets": {},
 | 
			
		||||
                # True if match was successful
 | 
			
		||||
                "match": successful_match,
 | 
			
		||||
                # Text of the sentence template that matched (or was closest)
 | 
			
		||||
                "sentence_template": "",
 | 
			
		||||
                # When match is incomplete, this will contain the best slot guesses
 | 
			
		||||
                "unmatched_slots": _get_unmatched_slots(intent_result),
 | 
			
		||||
                # True if match was not exact
 | 
			
		||||
                "fuzzy_match": False,
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if successful_match:
 | 
			
		||||
                result_dict["targets"] = {
 | 
			
		||||
                    state.entity_id: {"matched": is_matched}
 | 
			
		||||
                    for state, is_matched in _get_debug_targets(
 | 
			
		||||
                        self.hass, intent_result
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            if intent_result.intent_sentence is not None:
 | 
			
		||||
                result_dict["sentence_template"] = intent_result.intent_sentence.text
 | 
			
		||||
 | 
			
		||||
            if intent_result.intent_metadata:
 | 
			
		||||
                # Inspect metadata to determine if this matched a custom sentence
 | 
			
		||||
                if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE):
 | 
			
		||||
                    result_dict["source"] = "custom"
 | 
			
		||||
                    result_dict["file"] = intent_result.intent_metadata.get(
 | 
			
		||||
                        METADATA_CUSTOM_FILE
 | 
			
		||||
                    )
 | 
			
		||||
                else:
 | 
			
		||||
                    result_dict["source"] = "builtin"
 | 
			
		||||
 | 
			
		||||
                result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
 | 
			
		||||
                    METADATA_FUZZY_MATCH, False
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        return result_dict
 | 
			
		||||
 | 
			
		||||
    async def _async_handle_message(
 | 
			
		||||
        self,
 | 
			
		||||
        user_input: ConversationInput,
 | 
			
		||||
@@ -890,7 +969,7 @@ class DefaultAgent(ConversationEntity):
 | 
			
		||||
    ) -> str:
 | 
			
		||||
        # Get first matched or unmatched state.
 | 
			
		||||
        # This is available in the response template as "state".
 | 
			
		||||
        state1: core.State | None = None
 | 
			
		||||
        state1: State | None = None
 | 
			
		||||
        if intent_response.matched_states:
 | 
			
		||||
            state1 = intent_response.matched_states[0]
 | 
			
		||||
        elif intent_response.unmatched_states:
 | 
			
		||||
@@ -1528,6 +1607,10 @@ class DefaultAgent(ConversationEntity):
 | 
			
		||||
            return None
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    async def async_get_language_scores(self) -> dict[str, LanguageScores]:
 | 
			
		||||
        """Get support scores per language."""
 | 
			
		||||
        return await self.hass.async_add_executor_job(get_language_scores)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _make_error_result(
 | 
			
		||||
    language: str,
 | 
			
		||||
@@ -1589,7 +1672,7 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_match_error_response(
 | 
			
		||||
    hass: core.HomeAssistant,
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    match_error: intent.MatchFailedError,
 | 
			
		||||
) -> tuple[ErrorKey, dict[str, Any]]:
 | 
			
		||||
    """Return key and template arguments for error when target matching fails."""
 | 
			
		||||
@@ -1724,3 +1807,75 @@ def _collect_list_references(expression: Expression, list_names: set[str]) -> No
 | 
			
		||||
    elif isinstance(expression, ListReference):
 | 
			
		||||
        # {list}
 | 
			
		||||
        list_names.add(expression.slot_name)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_debug_targets(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    result: RecognizeResult,
 | 
			
		||||
) -> Iterable[tuple[State, bool]]:
 | 
			
		||||
    """Yield state/is_matched pairs for a hassil recognition."""
 | 
			
		||||
    entities = result.entities
 | 
			
		||||
 | 
			
		||||
    name: str | None = None
 | 
			
		||||
    area_name: str | None = None
 | 
			
		||||
    domains: set[str] | None = None
 | 
			
		||||
    device_classes: set[str] | None = None
 | 
			
		||||
    state_names: set[str] | None = None
 | 
			
		||||
 | 
			
		||||
    if "name" in entities:
 | 
			
		||||
        name = str(entities["name"].value)
 | 
			
		||||
 | 
			
		||||
    if "area" in entities:
 | 
			
		||||
        area_name = str(entities["area"].value)
 | 
			
		||||
 | 
			
		||||
    if "domain" in entities:
 | 
			
		||||
        domains = set(cv.ensure_list(entities["domain"].value))
 | 
			
		||||
 | 
			
		||||
    if "device_class" in entities:
 | 
			
		||||
        device_classes = set(cv.ensure_list(entities["device_class"].value))
 | 
			
		||||
 | 
			
		||||
    if "state" in entities:
 | 
			
		||||
        # HassGetState only
 | 
			
		||||
        state_names = set(cv.ensure_list(entities["state"].value))
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
        (name is None)
 | 
			
		||||
        and (area_name is None)
 | 
			
		||||
        and (not domains)
 | 
			
		||||
        and (not device_classes)
 | 
			
		||||
        and (not state_names)
 | 
			
		||||
    ):
 | 
			
		||||
        # Avoid "matching" all entities when there is no filter
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    states = intent.async_match_states(
 | 
			
		||||
        hass,
 | 
			
		||||
        name=name,
 | 
			
		||||
        area_name=area_name,
 | 
			
		||||
        domains=domains,
 | 
			
		||||
        device_classes=device_classes,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    for state in states:
 | 
			
		||||
        # For queries, a target is "matched" based on its state
 | 
			
		||||
        is_matched = (state_names is None) or (state.state in state_names)
 | 
			
		||||
        yield state, is_matched
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_unmatched_slots(
 | 
			
		||||
    result: RecognizeResult,
 | 
			
		||||
) -> dict[str, str | int | float]:
 | 
			
		||||
    """Return a dict of unmatched text/range slot entities."""
 | 
			
		||||
    unmatched_slots: dict[str, str | int | float] = {}
 | 
			
		||||
    for entity in result.unmatched_entities_list:
 | 
			
		||||
        if isinstance(entity, UnmatchedTextEntity):
 | 
			
		||||
            if entity.text == MISSING_ENTITY:
 | 
			
		||||
                # Don't report <missing> since these are just missing context
 | 
			
		||||
                # slots.
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            unmatched_slots[entity.name] = entity.text
 | 
			
		||||
        elif isinstance(entity, UnmatchedRangeEntity):
 | 
			
		||||
            unmatched_slots[entity.name] = entity.value
 | 
			
		||||
 | 
			
		||||
    return unmatched_slots
 | 
			
		||||
 
 | 
			
		||||
@@ -2,21 +2,16 @@
 | 
			
		||||
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from collections.abc import Iterable
 | 
			
		||||
from dataclasses import asdict
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from aiohttp import web
 | 
			
		||||
from hassil.recognize import MISSING_ENTITY, RecognizeResult
 | 
			
		||||
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
 | 
			
		||||
from home_assistant_intents import get_language_scores
 | 
			
		||||
import voluptuous as vol
 | 
			
		||||
 | 
			
		||||
from homeassistant.components import http, websocket_api
 | 
			
		||||
from homeassistant.components.http.data_validator import RequestDataValidator
 | 
			
		||||
from homeassistant.const import MATCH_ALL
 | 
			
		||||
from homeassistant.core import HomeAssistant, State, callback
 | 
			
		||||
from homeassistant.helpers import config_validation as cv, intent
 | 
			
		||||
from homeassistant.core import HomeAssistant, callback
 | 
			
		||||
from homeassistant.util import language as language_util
 | 
			
		||||
 | 
			
		||||
from .agent_manager import (
 | 
			
		||||
@@ -26,11 +21,6 @@ from .agent_manager import (
 | 
			
		||||
    get_agent_manager,
 | 
			
		||||
)
 | 
			
		||||
from .const import DATA_COMPONENT
 | 
			
		||||
from .default_agent import (
 | 
			
		||||
    METADATA_CUSTOM_FILE,
 | 
			
		||||
    METADATA_CUSTOM_SENTENCE,
 | 
			
		||||
    METADATA_FUZZY_MATCH,
 | 
			
		||||
)
 | 
			
		||||
from .entity import ConversationEntity
 | 
			
		||||
from .models import ConversationInput
 | 
			
		||||
 | 
			
		||||
@@ -206,150 +196,12 @@ async def websocket_hass_agent_debug(
 | 
			
		||||
            language=msg.get("language", hass.config.language),
 | 
			
		||||
            agent_id=agent.entity_id,
 | 
			
		||||
        )
 | 
			
		||||
        result_dict: dict[str, Any] | None = None
 | 
			
		||||
 | 
			
		||||
        if trigger_result := await agent.async_recognize_sentence_trigger(user_input):
 | 
			
		||||
            result_dict = {
 | 
			
		||||
                # Matched a user-defined sentence trigger.
 | 
			
		||||
                # We can't provide the response here without executing the
 | 
			
		||||
                # trigger.
 | 
			
		||||
                "match": True,
 | 
			
		||||
                "source": "trigger",
 | 
			
		||||
                "sentence_template": trigger_result.sentence_template or "",
 | 
			
		||||
            }
 | 
			
		||||
        elif intent_result := await agent.async_recognize_intent(user_input):
 | 
			
		||||
            successful_match = not intent_result.unmatched_entities
 | 
			
		||||
            result_dict = {
 | 
			
		||||
                # Name of the matching intent (or the closest)
 | 
			
		||||
                "intent": {
 | 
			
		||||
                    "name": intent_result.intent.name,
 | 
			
		||||
                },
 | 
			
		||||
                # Slot values that would be received by the intent
 | 
			
		||||
                "slots": {  # direct access to values
 | 
			
		||||
                    entity_key: entity.text or entity.value
 | 
			
		||||
                    for entity_key, entity in intent_result.entities.items()
 | 
			
		||||
                },
 | 
			
		||||
                # Extra slot details, such as the originally matched text
 | 
			
		||||
                "details": {
 | 
			
		||||
                    entity_key: {
 | 
			
		||||
                        "name": entity.name,
 | 
			
		||||
                        "value": entity.value,
 | 
			
		||||
                        "text": entity.text,
 | 
			
		||||
                    }
 | 
			
		||||
                    for entity_key, entity in intent_result.entities.items()
 | 
			
		||||
                },
 | 
			
		||||
                # Entities/areas/etc. that would be targeted
 | 
			
		||||
                "targets": {},
 | 
			
		||||
                # True if match was successful
 | 
			
		||||
                "match": successful_match,
 | 
			
		||||
                # Text of the sentence template that matched (or was closest)
 | 
			
		||||
                "sentence_template": "",
 | 
			
		||||
                # When match is incomplete, this will contain the best slot guesses
 | 
			
		||||
                "unmatched_slots": _get_unmatched_slots(intent_result),
 | 
			
		||||
                # True if match was not exact
 | 
			
		||||
                "fuzzy_match": False,
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if successful_match:
 | 
			
		||||
                result_dict["targets"] = {
 | 
			
		||||
                    state.entity_id: {"matched": is_matched}
 | 
			
		||||
                    for state, is_matched in _get_debug_targets(hass, intent_result)
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            if intent_result.intent_sentence is not None:
 | 
			
		||||
                result_dict["sentence_template"] = intent_result.intent_sentence.text
 | 
			
		||||
 | 
			
		||||
            if intent_result.intent_metadata:
 | 
			
		||||
                # Inspect metadata to determine if this matched a custom sentence
 | 
			
		||||
                if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE):
 | 
			
		||||
                    result_dict["source"] = "custom"
 | 
			
		||||
                    result_dict["file"] = intent_result.intent_metadata.get(
 | 
			
		||||
                        METADATA_CUSTOM_FILE
 | 
			
		||||
                    )
 | 
			
		||||
                else:
 | 
			
		||||
                    result_dict["source"] = "builtin"
 | 
			
		||||
 | 
			
		||||
                result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
 | 
			
		||||
                    METADATA_FUZZY_MATCH, False
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        result_dict = await agent.async_debug_recognize(user_input)
 | 
			
		||||
        result_dicts.append(result_dict)
 | 
			
		||||
 | 
			
		||||
    connection.send_result(msg["id"], {"results": result_dicts})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_debug_targets(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    result: RecognizeResult,
 | 
			
		||||
) -> Iterable[tuple[State, bool]]:
 | 
			
		||||
    """Yield state/is_matched pairs for a hassil recognition."""
 | 
			
		||||
    entities = result.entities
 | 
			
		||||
 | 
			
		||||
    name: str | None = None
 | 
			
		||||
    area_name: str | None = None
 | 
			
		||||
    domains: set[str] | None = None
 | 
			
		||||
    device_classes: set[str] | None = None
 | 
			
		||||
    state_names: set[str] | None = None
 | 
			
		||||
 | 
			
		||||
    if "name" in entities:
 | 
			
		||||
        name = str(entities["name"].value)
 | 
			
		||||
 | 
			
		||||
    if "area" in entities:
 | 
			
		||||
        area_name = str(entities["area"].value)
 | 
			
		||||
 | 
			
		||||
    if "domain" in entities:
 | 
			
		||||
        domains = set(cv.ensure_list(entities["domain"].value))
 | 
			
		||||
 | 
			
		||||
    if "device_class" in entities:
 | 
			
		||||
        device_classes = set(cv.ensure_list(entities["device_class"].value))
 | 
			
		||||
 | 
			
		||||
    if "state" in entities:
 | 
			
		||||
        # HassGetState only
 | 
			
		||||
        state_names = set(cv.ensure_list(entities["state"].value))
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
        (name is None)
 | 
			
		||||
        and (area_name is None)
 | 
			
		||||
        and (not domains)
 | 
			
		||||
        and (not device_classes)
 | 
			
		||||
        and (not state_names)
 | 
			
		||||
    ):
 | 
			
		||||
        # Avoid "matching" all entities when there is no filter
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    states = intent.async_match_states(
 | 
			
		||||
        hass,
 | 
			
		||||
        name=name,
 | 
			
		||||
        area_name=area_name,
 | 
			
		||||
        domains=domains,
 | 
			
		||||
        device_classes=device_classes,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    for state in states:
 | 
			
		||||
        # For queries, a target is "matched" based on its state
 | 
			
		||||
        is_matched = (state_names is None) or (state.state in state_names)
 | 
			
		||||
        yield state, is_matched
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_unmatched_slots(
 | 
			
		||||
    result: RecognizeResult,
 | 
			
		||||
) -> dict[str, str | int | float]:
 | 
			
		||||
    """Return a dict of unmatched text/range slot entities."""
 | 
			
		||||
    unmatched_slots: dict[str, str | int | float] = {}
 | 
			
		||||
    for entity in result.unmatched_entities_list:
 | 
			
		||||
        if isinstance(entity, UnmatchedTextEntity):
 | 
			
		||||
            if entity.text == MISSING_ENTITY:
 | 
			
		||||
                # Don't report <missing> since these are just missing context
 | 
			
		||||
                # slots.
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            unmatched_slots[entity.name] = entity.text
 | 
			
		||||
        elif isinstance(entity, UnmatchedRangeEntity):
 | 
			
		||||
            unmatched_slots[entity.name] = entity.value
 | 
			
		||||
 | 
			
		||||
    return unmatched_slots
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@websocket_api.websocket_command(
 | 
			
		||||
    {
 | 
			
		||||
        vol.Required("type"): "conversation/agent/homeassistant/language_scores",
 | 
			
		||||
@@ -364,10 +216,13 @@ async def websocket_hass_agent_language_scores(
 | 
			
		||||
    msg: dict[str, Any],
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Get support scores per language."""
 | 
			
		||||
    agent = get_agent_manager(hass).default_agent
 | 
			
		||||
    assert agent is not None
 | 
			
		||||
 | 
			
		||||
    language = msg.get("language", hass.config.language)
 | 
			
		||||
    country = msg.get("country", hass.config.country)
 | 
			
		||||
 | 
			
		||||
    scores = await hass.async_add_executor_job(get_language_scores)
 | 
			
		||||
    scores = await agent.async_get_language_scores()
 | 
			
		||||
    matching_langs = language_util.matches(language, scores.keys(), country=country)
 | 
			
		||||
    preferred_lang = matching_langs[0] if matching_langs else language
 | 
			
		||||
    result = {
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
 | 
			
		||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
@@ -42,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
 | 
			
		||||
    session = async_get_clientsession(hass)
 | 
			
		||||
    host = conf[CONF_HOST]
 | 
			
		||||
    try:
 | 
			
		||||
        async with asyncio.timeout(TIMEOUT):
 | 
			
		||||
        async with asyncio.timeout(TIMEOUT_SEC):
 | 
			
		||||
            device: Appliance = await DaikinFactory(
 | 
			
		||||
                host,
 | 
			
		||||
                session,
 | 
			
		||||
@@ -53,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
 | 
			
		||||
            )
 | 
			
		||||
        _LOGGER.debug("Connection to %s successful", host)
 | 
			
		||||
    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
 | 
			
		||||
    except ClientConnectionError as err:
 | 
			
		||||
        _LOGGER.debug("ClientConnectionError to %s", host)
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
 | 
			
		||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
 | 
			
		||||
from homeassistant.util.ssl import client_context_no_verify
 | 
			
		||||
 | 
			
		||||
from .const import DOMAIN, KEY_MAC, TIMEOUT
 | 
			
		||||
from .const import DOMAIN, KEY_MAC, TIMEOUT_SEC
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -84,7 +84,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
            password = None
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            async with asyncio.timeout(TIMEOUT):
 | 
			
		||||
            async with asyncio.timeout(TIMEOUT_SEC):
 | 
			
		||||
                device: Appliance = await DaikinFactory(
 | 
			
		||||
                    host,
 | 
			
		||||
                    async_get_clientsession(self.hass),
 | 
			
		||||
 
 | 
			
		||||
@@ -24,4 +24,4 @@ ATTR_STATE_OFF = "off"
 | 
			
		||||
KEY_MAC = "mac"
 | 
			
		||||
KEY_IP = "ip"
 | 
			
		||||
 | 
			
		||||
TIMEOUT = 60
 | 
			
		||||
TIMEOUT_SEC = 120
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
 | 
			
		||||
 | 
			
		||||
from .const import DOMAIN
 | 
			
		||||
from .const import DOMAIN, TIMEOUT_SEC
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -28,7 +28,7 @@ class DaikinCoordinator(DataUpdateCoordinator[None]):
 | 
			
		||||
            _LOGGER,
 | 
			
		||||
            config_entry=entry,
 | 
			
		||||
            name=device.values.get("name", DOMAIN),
 | 
			
		||||
            update_interval=timedelta(seconds=60),
 | 
			
		||||
            update_interval=timedelta(seconds=TIMEOUT_SEC),
 | 
			
		||||
        )
 | 
			
		||||
        self.device = device
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -32,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
            entry,
 | 
			
		||||
            options={**entry.options, CONF_SOURCE: source_entity_id},
 | 
			
		||||
        )
 | 
			
		||||
        hass.config_entries.async_schedule_reload(entry.entry_id)
 | 
			
		||||
 | 
			
		||||
    entry.async_on_unload(
 | 
			
		||||
        async_handle_source_entity_changes(
 | 
			
		||||
@@ -46,15 +47,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
        )
 | 
			
		||||
    )
 | 
			
		||||
    await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,))
 | 
			
		||||
    entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
 | 
			
		||||
    return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
 | 
			
		||||
    """Update listener, called when the config entry options are changed."""
 | 
			
		||||
    await hass.config_entries.async_reload(entry.entry_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
    """Unload a config entry."""
 | 
			
		||||
    return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,))
 | 
			
		||||
 
 | 
			
		||||
@@ -140,6 +140,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
 | 
			
		||||
 | 
			
		||||
    config_flow = CONFIG_FLOW
 | 
			
		||||
    options_flow = OPTIONS_FLOW
 | 
			
		||||
    options_flow_reloads = True
 | 
			
		||||
 | 
			
		||||
    VERSION = 1
 | 
			
		||||
    MINOR_VERSION = 4
 | 
			
		||||
 
 | 
			
		||||
@@ -6,12 +6,13 @@ from typing import TYPE_CHECKING, Any, Protocol
 | 
			
		||||
 | 
			
		||||
import voluptuous as vol
 | 
			
		||||
 | 
			
		||||
from homeassistant.const import CONF_DOMAIN
 | 
			
		||||
from homeassistant.const import CONF_DOMAIN, CONF_OPTIONS
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers import config_validation as cv
 | 
			
		||||
from homeassistant.helpers.condition import (
 | 
			
		||||
    Condition,
 | 
			
		||||
    ConditionCheckerType,
 | 
			
		||||
    ConditionConfig,
 | 
			
		||||
    trace_condition_function,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.helpers.typing import ConfigType
 | 
			
		||||
@@ -55,19 +56,40 @@ class DeviceAutomationConditionProtocol(Protocol):
 | 
			
		||||
class DeviceCondition(Condition):
 | 
			
		||||
    """Device condition."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
 | 
			
		||||
        """Initialize condition."""
 | 
			
		||||
        self._config = config
 | 
			
		||||
        self._hass = hass
 | 
			
		||||
    _hass: HomeAssistant
 | 
			
		||||
    _config: ConfigType
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    async def async_validate_complete_config(
 | 
			
		||||
        cls, hass: HomeAssistant, complete_config: ConfigType
 | 
			
		||||
    ) -> ConfigType:
 | 
			
		||||
        """Validate complete config."""
 | 
			
		||||
        complete_config = await async_validate_device_automation_config(
 | 
			
		||||
            hass,
 | 
			
		||||
            complete_config,
 | 
			
		||||
            cv.DEVICE_CONDITION_SCHEMA,
 | 
			
		||||
            DeviceAutomationType.CONDITION,
 | 
			
		||||
        )
 | 
			
		||||
        # Since we don't want to migrate device conditions to a new format
 | 
			
		||||
        # we just pass the entire config as options.
 | 
			
		||||
        complete_config[CONF_OPTIONS] = complete_config.copy()
 | 
			
		||||
        return complete_config
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    async def async_validate_config(
 | 
			
		||||
        cls, hass: HomeAssistant, config: ConfigType
 | 
			
		||||
    ) -> ConfigType:
 | 
			
		||||
        """Validate device condition config."""
 | 
			
		||||
        return await async_validate_device_automation_config(
 | 
			
		||||
            hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION
 | 
			
		||||
        )
 | 
			
		||||
        """Validate config.
 | 
			
		||||
 | 
			
		||||
        This is here just to satisfy the abstract class interface. It is never called.
 | 
			
		||||
        """
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
 | 
			
		||||
        """Initialize condition."""
 | 
			
		||||
        self._hass = hass
 | 
			
		||||
        assert config.options is not None
 | 
			
		||||
        self._config = config.options
 | 
			
		||||
 | 
			
		||||
    async def async_get_checker(self) -> condition.ConditionCheckerType:
 | 
			
		||||
        """Test a device condition."""
 | 
			
		||||
 
 | 
			
		||||
@@ -126,7 +126,7 @@ class DevoloRemoteControl(DevoloDeviceEntity, BinarySensorEntity):
 | 
			
		||||
        self._attr_translation_key = "button"
 | 
			
		||||
        self._attr_translation_placeholders = {"key": str(key)}
 | 
			
		||||
 | 
			
		||||
    def _sync(self, message: tuple) -> None:
 | 
			
		||||
    def sync_callback(self, message: tuple) -> None:
 | 
			
		||||
        """Update the binary sensor state."""
 | 
			
		||||
        if (
 | 
			
		||||
            message[0] == self._remote_control_property.element_uid
 | 
			
		||||
 
 | 
			
		||||
@@ -48,7 +48,6 @@ class DevoloDeviceEntity(Entity):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.subscriber: Subscriber | None = None
 | 
			
		||||
        self.sync_callback = self._sync
 | 
			
		||||
 | 
			
		||||
        self._value: float
 | 
			
		||||
 | 
			
		||||
@@ -69,7 +68,7 @@ class DevoloDeviceEntity(Entity):
 | 
			
		||||
            self._device_instance.uid, self.subscriber
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def _sync(self, message: tuple) -> None:
 | 
			
		||||
    def sync_callback(self, message: tuple) -> None:
 | 
			
		||||
        """Update the state."""
 | 
			
		||||
        if message[0] == self._attr_unique_id:
 | 
			
		||||
            self._value = message[1]
 | 
			
		||||
 
 | 
			
		||||
@@ -185,7 +185,7 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity):
 | 
			
		||||
        """
 | 
			
		||||
        return f"{self._attr_unique_id}_{self._sensor_type}"
 | 
			
		||||
 | 
			
		||||
    def _sync(self, message: tuple) -> None:
 | 
			
		||||
    def sync_callback(self, message: tuple) -> None:
 | 
			
		||||
        """Update the consumption sensor state."""
 | 
			
		||||
        if message[0] == self._attr_unique_id:
 | 
			
		||||
            self._value = getattr(
 | 
			
		||||
 
 | 
			
		||||
@@ -13,8 +13,3 @@ class Subscriber:
 | 
			
		||||
        """Initiate the subscriber."""
 | 
			
		||||
        self.name = name
 | 
			
		||||
        self.callback = callback
 | 
			
		||||
 | 
			
		||||
    def update(self, message: str) -> None:
 | 
			
		||||
        """Trigger hass to update the device."""
 | 
			
		||||
        _LOGGER.debug('%s got message "%s"', self.name, message)
 | 
			
		||||
        self.callback(message)
 | 
			
		||||
 
 | 
			
		||||
@@ -64,7 +64,7 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity):
 | 
			
		||||
        """Switch off the device."""
 | 
			
		||||
        self._binary_switch_property.set(state=False)
 | 
			
		||||
 | 
			
		||||
    def _sync(self, message: tuple) -> None:
 | 
			
		||||
    def sync_callback(self, message: tuple) -> None:
 | 
			
		||||
        """Update the binary switch state and consumption."""
 | 
			
		||||
        if message[0].startswith("devolo.BinarySwitch"):
 | 
			
		||||
            self._attr_is_on = self._device_instance.binary_switch_property[
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,6 @@
 | 
			
		||||
  "requirements": [
 | 
			
		||||
    "aiodhcpwatcher==1.2.1",
 | 
			
		||||
    "aiodiscover==2.7.1",
 | 
			
		||||
    "cached-ipaddress==0.10.0"
 | 
			
		||||
    "cached-ipaddress==1.0.1"
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -56,16 +56,16 @@ async def async_setup_entry(
 | 
			
		||||
    hostname = entry.data[CONF_HOSTNAME]
 | 
			
		||||
    name = entry.data[CONF_NAME]
 | 
			
		||||
 | 
			
		||||
    resolver_ipv4 = entry.options[CONF_RESOLVER]
 | 
			
		||||
    resolver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
 | 
			
		||||
    nameserver_ipv4 = entry.options[CONF_RESOLVER]
 | 
			
		||||
    nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
 | 
			
		||||
    port_ipv4 = entry.options[CONF_PORT]
 | 
			
		||||
    port_ipv6 = entry.options[CONF_PORT_IPV6]
 | 
			
		||||
 | 
			
		||||
    entities = []
 | 
			
		||||
    if entry.data[CONF_IPV4]:
 | 
			
		||||
        entities.append(WanIpSensor(name, hostname, resolver_ipv4, False, port_ipv4))
 | 
			
		||||
        entities.append(WanIpSensor(name, hostname, nameserver_ipv4, False, port_ipv4))
 | 
			
		||||
    if entry.data[CONF_IPV6]:
 | 
			
		||||
        entities.append(WanIpSensor(name, hostname, resolver_ipv6, True, port_ipv6))
 | 
			
		||||
        entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6))
 | 
			
		||||
 | 
			
		||||
    async_add_entities(entities, update_before_add=True)
 | 
			
		||||
 | 
			
		||||
@@ -77,11 +77,13 @@ class WanIpSensor(SensorEntity):
 | 
			
		||||
    _attr_translation_key = "dnsip"
 | 
			
		||||
    _unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"})
 | 
			
		||||
 | 
			
		||||
    resolver: aiodns.DNSResolver
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        name: str,
 | 
			
		||||
        hostname: str,
 | 
			
		||||
        resolver: str,
 | 
			
		||||
        nameserver: str,
 | 
			
		||||
        ipv6: bool,
 | 
			
		||||
        port: int,
 | 
			
		||||
    ) -> None:
 | 
			
		||||
@@ -90,11 +92,11 @@ class WanIpSensor(SensorEntity):
 | 
			
		||||
        self._attr_unique_id = f"{hostname}_{ipv6}"
 | 
			
		||||
        self.hostname = hostname
 | 
			
		||||
        self.port = port
 | 
			
		||||
        self._resolver = resolver
 | 
			
		||||
        self.nameserver = nameserver
 | 
			
		||||
        self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A"
 | 
			
		||||
        self._retries = DEFAULT_RETRIES
 | 
			
		||||
        self._attr_extra_state_attributes = {
 | 
			
		||||
            "resolver": resolver,
 | 
			
		||||
            "resolver": nameserver,
 | 
			
		||||
            "querytype": self.querytype,
 | 
			
		||||
        }
 | 
			
		||||
        self._attr_device_info = DeviceInfo(
 | 
			
		||||
@@ -104,13 +106,13 @@ class WanIpSensor(SensorEntity):
 | 
			
		||||
            model=aiodns.__version__,
 | 
			
		||||
            name=name,
 | 
			
		||||
        )
 | 
			
		||||
        self.resolver: aiodns.DNSResolver
 | 
			
		||||
        self.create_dns_resolver()
 | 
			
		||||
 | 
			
		||||
    def create_dns_resolver(self) -> None:
 | 
			
		||||
        """Create the DNS resolver."""
 | 
			
		||||
        self.resolver = aiodns.DNSResolver(tcp_port=self.port, udp_port=self.port)
 | 
			
		||||
        self.resolver.nameservers = [self._resolver]
 | 
			
		||||
        self.resolver = aiodns.DNSResolver(
 | 
			
		||||
            nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    async def async_update(self) -> None:
 | 
			
		||||
        """Get the current DNS IP address for hostname."""
 | 
			
		||||
 
 | 
			
		||||
@@ -116,6 +116,9 @@
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "select": {
 | 
			
		||||
      "active_map": {
 | 
			
		||||
        "default": "mdi:floor-plan"
 | 
			
		||||
      },
 | 
			
		||||
      "water_amount": {
 | 
			
		||||
        "default": "mdi:water"
 | 
			
		||||
      },
 | 
			
		||||
 
 | 
			
		||||
@@ -6,5 +6,5 @@
 | 
			
		||||
  "documentation": "https://www.home-assistant.io/integrations/ecovacs",
 | 
			
		||||
  "iot_class": "cloud_push",
 | 
			
		||||
  "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
 | 
			
		||||
  "requirements": ["py-sucks==0.9.11", "deebot-client==15.1.0"]
 | 
			
		||||
  "requirements": ["py-sucks==0.9.11", "deebot-client==15.0.0"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,12 +2,13 @@
 | 
			
		||||
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from typing import Any
 | 
			
		||||
from typing import TYPE_CHECKING, Any
 | 
			
		||||
 | 
			
		||||
from deebot_client.capabilities import CapabilitySetTypes
 | 
			
		||||
from deebot_client.capabilities import CapabilityMap, CapabilitySet, CapabilitySetTypes
 | 
			
		||||
from deebot_client.device import Device
 | 
			
		||||
from deebot_client.events import WorkModeEvent
 | 
			
		||||
from deebot_client.events.base import Event
 | 
			
		||||
from deebot_client.events.map import CachedMapInfoEvent, MajorMapEvent
 | 
			
		||||
from deebot_client.events.water_info import WaterAmountEvent
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
 | 
			
		||||
@@ -16,7 +17,11 @@ from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
 | 
			
		||||
 | 
			
		||||
from . import EcovacsConfigEntry
 | 
			
		||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity
 | 
			
		||||
from .entity import (
 | 
			
		||||
    EcovacsCapabilityEntityDescription,
 | 
			
		||||
    EcovacsDescriptionEntity,
 | 
			
		||||
    EcovacsEntity,
 | 
			
		||||
)
 | 
			
		||||
from .util import get_name_key, get_supported_entities
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -66,6 +71,12 @@ async def async_setup_entry(
 | 
			
		||||
    entities = get_supported_entities(
 | 
			
		||||
        controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS
 | 
			
		||||
    )
 | 
			
		||||
    entities.extend(
 | 
			
		||||
        EcovacsActiveMapSelectEntity(device, device.capabilities.map)
 | 
			
		||||
        for device in controller.devices
 | 
			
		||||
        if (map_cap := device.capabilities.map)
 | 
			
		||||
        and isinstance(map_cap.major, CapabilitySet)
 | 
			
		||||
    )
 | 
			
		||||
    if entities:
 | 
			
		||||
        async_add_entities(entities)
 | 
			
		||||
 | 
			
		||||
@@ -103,3 +114,76 @@ class EcovacsSelectEntity[EventT: Event](
 | 
			
		||||
    async def async_select_option(self, option: str) -> None:
 | 
			
		||||
        """Change the selected option."""
 | 
			
		||||
        await self._device.execute_command(self._capability.set(option))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EcovacsActiveMapSelectEntity(
 | 
			
		||||
    EcovacsEntity[CapabilityMap],
 | 
			
		||||
    SelectEntity,
 | 
			
		||||
):
 | 
			
		||||
    """Ecovacs active map select entity."""
 | 
			
		||||
 | 
			
		||||
    entity_description = SelectEntityDescription(
 | 
			
		||||
        key="active_map",
 | 
			
		||||
        translation_key="active_map",
 | 
			
		||||
        entity_category=EntityCategory.CONFIG,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        device: Device,
 | 
			
		||||
        capability: CapabilityMap,
 | 
			
		||||
        **kwargs: Any,
 | 
			
		||||
    ) -> None:
 | 
			
		||||
        """Initialize entity."""
 | 
			
		||||
        super().__init__(device, capability, **kwargs)
 | 
			
		||||
        self._option_to_id: dict[str, str] = {}
 | 
			
		||||
        self._id_to_option: dict[str, str] = {}
 | 
			
		||||
 | 
			
		||||
        self._handle_on_cached_map(
 | 
			
		||||
            device.events.get_last_event(CachedMapInfoEvent)
 | 
			
		||||
            or CachedMapInfoEvent(set())
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def _handle_on_cached_map(self, event: CachedMapInfoEvent) -> None:
 | 
			
		||||
        self._id_to_option.clear()
 | 
			
		||||
        self._option_to_id.clear()
 | 
			
		||||
 | 
			
		||||
        for map_info in event.maps:
 | 
			
		||||
            name = map_info.name if map_info.name else map_info.id
 | 
			
		||||
            self._id_to_option[map_info.id] = name
 | 
			
		||||
            self._option_to_id[name] = map_info.id
 | 
			
		||||
 | 
			
		||||
            if map_info.using:
 | 
			
		||||
                self._attr_current_option = name
 | 
			
		||||
 | 
			
		||||
        if self._attr_current_option not in self._option_to_id:
 | 
			
		||||
            self._attr_current_option = None
 | 
			
		||||
 | 
			
		||||
        # Sort named maps first, then numeric IDs (unnamed maps during building) in ascending order.
 | 
			
		||||
        self._attr_options = sorted(
 | 
			
		||||
            self._option_to_id.keys(), key=lambda x: (x.isdigit(), x.lower())
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    async def async_added_to_hass(self) -> None:
 | 
			
		||||
        """Set up the event listeners now that hass is ready."""
 | 
			
		||||
        await super().async_added_to_hass()
 | 
			
		||||
 | 
			
		||||
        async def on_cached_map(event: CachedMapInfoEvent) -> None:
 | 
			
		||||
            self._handle_on_cached_map(event)
 | 
			
		||||
            self.async_write_ha_state()
 | 
			
		||||
 | 
			
		||||
        self._subscribe(self._capability.cached_info.event, on_cached_map)
 | 
			
		||||
 | 
			
		||||
        async def on_major_map(event: MajorMapEvent) -> None:
 | 
			
		||||
            self._attr_current_option = self._id_to_option.get(event.map_id)
 | 
			
		||||
            self.async_write_ha_state()
 | 
			
		||||
 | 
			
		||||
        self._subscribe(self._capability.major.event, on_major_map)
 | 
			
		||||
 | 
			
		||||
    async def async_select_option(self, option: str) -> None:
 | 
			
		||||
        """Change the selected option."""
 | 
			
		||||
        if TYPE_CHECKING:
 | 
			
		||||
            assert isinstance(self._capability.major, CapabilitySet)
 | 
			
		||||
        await self._device.execute_command(
 | 
			
		||||
            self._capability.major.set(self._option_to_id[option])
 | 
			
		||||
        )
 | 
			
		||||
 
 | 
			
		||||
@@ -2,3 +2,4 @@ raw_get_positions:
 | 
			
		||||
  target:
 | 
			
		||||
    entity:
 | 
			
		||||
      domain: vacuum
 | 
			
		||||
      integration: ecovacs
 | 
			
		||||
 
 | 
			
		||||
@@ -178,6 +178,9 @@
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "select": {
 | 
			
		||||
      "active_map": {
 | 
			
		||||
        "name": "Active map"
 | 
			
		||||
      },
 | 
			
		||||
      "water_amount": {
 | 
			
		||||
        "name": "[%key:component::ecovacs::entity::number::water_amount::name%]",
 | 
			
		||||
        "state": {
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@
 | 
			
		||||
  "iot_class": "local_polling",
 | 
			
		||||
  "loggers": ["pyenphase"],
 | 
			
		||||
  "quality_scale": "platinum",
 | 
			
		||||
  "requirements": ["pyenphase==2.3.0"],
 | 
			
		||||
  "requirements": ["pyenphase==2.4.0"],
 | 
			
		||||
  "zeroconf": [
 | 
			
		||||
    {
 | 
			
		||||
      "type": "_enphase-envoy._tcp.local."
 | 
			
		||||
 
 | 
			
		||||
@@ -396,6 +396,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
 | 
			
		||||
        int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None,
 | 
			
		||||
    ]
 | 
			
		||||
    on_phase: str | None
 | 
			
		||||
    cttype: str | None = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
CT_NET_CONSUMPTION_SENSORS = (
 | 
			
		||||
@@ -409,6 +410,7 @@ CT_NET_CONSUMPTION_SENSORS = (
 | 
			
		||||
        suggested_display_precision=3,
 | 
			
		||||
        value_fn=attrgetter("energy_delivered"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.NET_CONSUMPTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="lifetime_net_production",
 | 
			
		||||
@@ -420,6 +422,7 @@ CT_NET_CONSUMPTION_SENSORS = (
 | 
			
		||||
        suggested_display_precision=3,
 | 
			
		||||
        value_fn=attrgetter("energy_received"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.NET_CONSUMPTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="net_consumption",
 | 
			
		||||
@@ -431,6 +434,7 @@ CT_NET_CONSUMPTION_SENSORS = (
 | 
			
		||||
        suggested_display_precision=3,
 | 
			
		||||
        value_fn=attrgetter("active_power"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.NET_CONSUMPTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="frequency",
 | 
			
		||||
@@ -442,6 +446,7 @@ CT_NET_CONSUMPTION_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("frequency"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.NET_CONSUMPTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="voltage",
 | 
			
		||||
@@ -454,6 +459,7 @@ CT_NET_CONSUMPTION_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("voltage"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.NET_CONSUMPTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="net_ct_current",
 | 
			
		||||
@@ -466,6 +472,7 @@ CT_NET_CONSUMPTION_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("current"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.NET_CONSUMPTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="net_ct_powerfactor",
 | 
			
		||||
@@ -476,6 +483,7 @@ CT_NET_CONSUMPTION_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("power_factor"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.NET_CONSUMPTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="net_consumption_ct_metering_status",
 | 
			
		||||
@@ -486,6 +494,7 @@ CT_NET_CONSUMPTION_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("metering_status"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.NET_CONSUMPTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="net_consumption_ct_status_flags",
 | 
			
		||||
@@ -495,6 +504,7 @@ CT_NET_CONSUMPTION_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.NET_CONSUMPTION,
 | 
			
		||||
    ),
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@@ -525,6 +535,7 @@ CT_PRODUCTION_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("frequency"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.PRODUCTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="production_ct_voltage",
 | 
			
		||||
@@ -537,6 +548,7 @@ CT_PRODUCTION_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("voltage"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.PRODUCTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="production_ct_current",
 | 
			
		||||
@@ -549,6 +561,7 @@ CT_PRODUCTION_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("current"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.PRODUCTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="production_ct_powerfactor",
 | 
			
		||||
@@ -559,6 +572,7 @@ CT_PRODUCTION_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("power_factor"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.PRODUCTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="production_ct_metering_status",
 | 
			
		||||
@@ -569,6 +583,7 @@ CT_PRODUCTION_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("metering_status"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.PRODUCTION,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="production_ct_status_flags",
 | 
			
		||||
@@ -578,6 +593,7 @@ CT_PRODUCTION_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.PRODUCTION,
 | 
			
		||||
    ),
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@@ -607,6 +623,7 @@ CT_STORAGE_SENSORS = (
 | 
			
		||||
        suggested_display_precision=3,
 | 
			
		||||
        value_fn=attrgetter("energy_delivered"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.STORAGE,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="lifetime_battery_charged",
 | 
			
		||||
@@ -618,6 +635,7 @@ CT_STORAGE_SENSORS = (
 | 
			
		||||
        suggested_display_precision=3,
 | 
			
		||||
        value_fn=attrgetter("energy_received"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.STORAGE,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="battery_discharge",
 | 
			
		||||
@@ -629,6 +647,7 @@ CT_STORAGE_SENSORS = (
 | 
			
		||||
        suggested_display_precision=3,
 | 
			
		||||
        value_fn=attrgetter("active_power"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.STORAGE,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="storage_ct_frequency",
 | 
			
		||||
@@ -640,6 +659,7 @@ CT_STORAGE_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("frequency"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.STORAGE,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="storage_voltage",
 | 
			
		||||
@@ -652,6 +672,7 @@ CT_STORAGE_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("voltage"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.STORAGE,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="storage_ct_current",
 | 
			
		||||
@@ -664,6 +685,7 @@ CT_STORAGE_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("current"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.STORAGE,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="storage_ct_powerfactor",
 | 
			
		||||
@@ -674,6 +696,7 @@ CT_STORAGE_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("power_factor"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.STORAGE,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="storage_ct_metering_status",
 | 
			
		||||
@@ -684,6 +707,7 @@ CT_STORAGE_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=attrgetter("metering_status"),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.STORAGE,
 | 
			
		||||
    ),
 | 
			
		||||
    EnvoyCTSensorEntityDescription(
 | 
			
		||||
        key="storage_ct_status_flags",
 | 
			
		||||
@@ -693,6 +717,7 @@ CT_STORAGE_SENSORS = (
 | 
			
		||||
        entity_registry_enabled_default=False,
 | 
			
		||||
        value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
 | 
			
		||||
        on_phase=None,
 | 
			
		||||
        cttype=CtType.STORAGE,
 | 
			
		||||
    ),
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@@ -1015,50 +1040,31 @@ async def async_setup_entry(
 | 
			
		||||
            for description in NET_CONSUMPTION_PHASE_SENSORS[use_phase]
 | 
			
		||||
            if phase is not None
 | 
			
		||||
        )
 | 
			
		||||
    # Add net consumption CT entities
 | 
			
		||||
    if ctmeter := envoy_data.ctmeter_consumption:
 | 
			
		||||
    # Add Current Transformer entities
 | 
			
		||||
    if envoy_data.ctmeters:
 | 
			
		||||
        entities.extend(
 | 
			
		||||
            EnvoyConsumptionCTEntity(coordinator, description)
 | 
			
		||||
            for description in CT_NET_CONSUMPTION_SENSORS
 | 
			
		||||
            if ctmeter.measurement_type == CtType.NET_CONSUMPTION
 | 
			
		||||
            EnvoyCTEntity(coordinator, description)
 | 
			
		||||
            for sensors in (
 | 
			
		||||
                CT_NET_CONSUMPTION_SENSORS,
 | 
			
		||||
                CT_PRODUCTION_SENSORS,
 | 
			
		||||
                CT_STORAGE_SENSORS,
 | 
			
		||||
            )
 | 
			
		||||
            for description in sensors
 | 
			
		||||
            if description.cttype in envoy_data.ctmeters
 | 
			
		||||
        )
 | 
			
		||||
    # For each net consumption ct phase reported add net consumption entities
 | 
			
		||||
    if phase_data := envoy_data.ctmeter_consumption_phases:
 | 
			
		||||
    # Add Current Transformer phase entities
 | 
			
		||||
    if ctmeters_phases := envoy_data.ctmeters_phases:
 | 
			
		||||
        entities.extend(
 | 
			
		||||
            EnvoyConsumptionCTPhaseEntity(coordinator, description)
 | 
			
		||||
            for use_phase, phase in phase_data.items()
 | 
			
		||||
            for description in CT_NET_CONSUMPTION_PHASE_SENSORS[use_phase]
 | 
			
		||||
            if phase.measurement_type == CtType.NET_CONSUMPTION
 | 
			
		||||
        )
 | 
			
		||||
    # Add production CT entities
 | 
			
		||||
    if ctmeter := envoy_data.ctmeter_production:
 | 
			
		||||
        entities.extend(
 | 
			
		||||
            EnvoyProductionCTEntity(coordinator, description)
 | 
			
		||||
            for description in CT_PRODUCTION_SENSORS
 | 
			
		||||
            if ctmeter.measurement_type == CtType.PRODUCTION
 | 
			
		||||
        )
 | 
			
		||||
    # For each production ct phase reported add production ct entities
 | 
			
		||||
    if phase_data := envoy_data.ctmeter_production_phases:
 | 
			
		||||
        entities.extend(
 | 
			
		||||
            EnvoyProductionCTPhaseEntity(coordinator, description)
 | 
			
		||||
            for use_phase, phase in phase_data.items()
 | 
			
		||||
            for description in CT_PRODUCTION_PHASE_SENSORS[use_phase]
 | 
			
		||||
            if phase.measurement_type == CtType.PRODUCTION
 | 
			
		||||
        )
 | 
			
		||||
    # Add storage CT entities
 | 
			
		||||
    if ctmeter := envoy_data.ctmeter_storage:
 | 
			
		||||
        entities.extend(
 | 
			
		||||
            EnvoyStorageCTEntity(coordinator, description)
 | 
			
		||||
            for description in CT_STORAGE_SENSORS
 | 
			
		||||
            if ctmeter.measurement_type == CtType.STORAGE
 | 
			
		||||
        )
 | 
			
		||||
    # For each storage ct phase reported add storage ct entities
 | 
			
		||||
    if phase_data := envoy_data.ctmeter_storage_phases:
 | 
			
		||||
        entities.extend(
 | 
			
		||||
            EnvoyStorageCTPhaseEntity(coordinator, description)
 | 
			
		||||
            for use_phase, phase in phase_data.items()
 | 
			
		||||
            for description in CT_STORAGE_PHASE_SENSORS[use_phase]
 | 
			
		||||
            if phase.measurement_type == CtType.STORAGE
 | 
			
		||||
            EnvoyCTPhaseEntity(coordinator, description)
 | 
			
		||||
            for sensors in (
 | 
			
		||||
                CT_NET_CONSUMPTION_PHASE_SENSORS,
 | 
			
		||||
                CT_PRODUCTION_PHASE_SENSORS,
 | 
			
		||||
                CT_STORAGE_PHASE_SENSORS,
 | 
			
		||||
            )
 | 
			
		||||
            for phase, descriptions in sensors.items()
 | 
			
		||||
            for description in descriptions
 | 
			
		||||
            if (cttype := description.cttype) in ctmeters_phases
 | 
			
		||||
            and phase in ctmeters_phases[cttype]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    if envoy_data.inverters:
 | 
			
		||||
@@ -1245,8 +1251,8 @@ class EnvoyNetConsumptionPhaseEntity(EnvoySystemSensorEntity):
 | 
			
		||||
        return self.entity_description.value_fn(system_net_consumption)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EnvoyConsumptionCTEntity(EnvoySystemSensorEntity):
 | 
			
		||||
    """Envoy net consumption CT entity."""
 | 
			
		||||
class EnvoyCTEntity(EnvoySystemSensorEntity):
 | 
			
		||||
    """Envoy CT entity."""
 | 
			
		||||
 | 
			
		||||
    entity_description: EnvoyCTSensorEntityDescription
 | 
			
		||||
 | 
			
		||||
@@ -1255,13 +1261,13 @@ class EnvoyConsumptionCTEntity(EnvoySystemSensorEntity):
 | 
			
		||||
        self,
 | 
			
		||||
    ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
 | 
			
		||||
        """Return the state of the CT sensor."""
 | 
			
		||||
        if (ctmeter := self.data.ctmeter_consumption) is None:
 | 
			
		||||
        if (cttype := self.entity_description.cttype) not in self.data.ctmeters:
 | 
			
		||||
            return None
 | 
			
		||||
        return self.entity_description.value_fn(ctmeter)
 | 
			
		||||
        return self.entity_description.value_fn(self.data.ctmeters[cttype])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EnvoyConsumptionCTPhaseEntity(EnvoySystemSensorEntity):
 | 
			
		||||
    """Envoy net consumption CT phase entity."""
 | 
			
		||||
class EnvoyCTPhaseEntity(EnvoySystemSensorEntity):
 | 
			
		||||
    """Envoy CT phase entity."""
 | 
			
		||||
 | 
			
		||||
    entity_description: EnvoyCTSensorEntityDescription
 | 
			
		||||
 | 
			
		||||
@@ -1272,78 +1278,14 @@ class EnvoyConsumptionCTPhaseEntity(EnvoySystemSensorEntity):
 | 
			
		||||
        """Return the state of the CT phase sensor."""
 | 
			
		||||
        if TYPE_CHECKING:
 | 
			
		||||
            assert self.entity_description.on_phase
 | 
			
		||||
        if (ctmeter := self.data.ctmeter_consumption_phases) is None:
 | 
			
		||||
        if (cttype := self.entity_description.cttype) not in self.data.ctmeters_phases:
 | 
			
		||||
            return None
 | 
			
		||||
        if (phase := self.entity_description.on_phase) not in self.data.ctmeters_phases[
 | 
			
		||||
            cttype
 | 
			
		||||
        ]:
 | 
			
		||||
            return None
 | 
			
		||||
        return self.entity_description.value_fn(
 | 
			
		||||
            ctmeter[self.entity_description.on_phase]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EnvoyProductionCTEntity(EnvoySystemSensorEntity):
 | 
			
		||||
    """Envoy net consumption CT entity."""
 | 
			
		||||
 | 
			
		||||
    entity_description: EnvoyCTSensorEntityDescription
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def native_value(
 | 
			
		||||
        self,
 | 
			
		||||
    ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
 | 
			
		||||
        """Return the state of the CT sensor."""
 | 
			
		||||
        if (ctmeter := self.data.ctmeter_production) is None:
 | 
			
		||||
            return None
 | 
			
		||||
        return self.entity_description.value_fn(ctmeter)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EnvoyProductionCTPhaseEntity(EnvoySystemSensorEntity):
 | 
			
		||||
    """Envoy net consumption CT phase entity."""
 | 
			
		||||
 | 
			
		||||
    entity_description: EnvoyCTSensorEntityDescription
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def native_value(
 | 
			
		||||
        self,
 | 
			
		||||
    ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
 | 
			
		||||
        """Return the state of the CT phase sensor."""
 | 
			
		||||
        if TYPE_CHECKING:
 | 
			
		||||
            assert self.entity_description.on_phase
 | 
			
		||||
        if (ctmeter := self.data.ctmeter_production_phases) is None:
 | 
			
		||||
            return None
 | 
			
		||||
        return self.entity_description.value_fn(
 | 
			
		||||
            ctmeter[self.entity_description.on_phase]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EnvoyStorageCTEntity(EnvoySystemSensorEntity):
 | 
			
		||||
    """Envoy net storage CT entity."""
 | 
			
		||||
 | 
			
		||||
    entity_description: EnvoyCTSensorEntityDescription
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def native_value(
 | 
			
		||||
        self,
 | 
			
		||||
    ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
 | 
			
		||||
        """Return the state of the CT sensor."""
 | 
			
		||||
        if (ctmeter := self.data.ctmeter_storage) is None:
 | 
			
		||||
            return None
 | 
			
		||||
        return self.entity_description.value_fn(ctmeter)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EnvoyStorageCTPhaseEntity(EnvoySystemSensorEntity):
 | 
			
		||||
    """Envoy net storage CT phase entity."""
 | 
			
		||||
 | 
			
		||||
    entity_description: EnvoyCTSensorEntityDescription
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def native_value(
 | 
			
		||||
        self,
 | 
			
		||||
    ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
 | 
			
		||||
        """Return the state of the CT phase sensor."""
 | 
			
		||||
        if TYPE_CHECKING:
 | 
			
		||||
            assert self.entity_description.on_phase
 | 
			
		||||
        if (ctmeter := self.data.ctmeter_storage_phases) is None:
 | 
			
		||||
            return None
 | 
			
		||||
        return self.entity_description.value_fn(
 | 
			
		||||
            ctmeter[self.entity_description.on_phase]
 | 
			
		||||
            self.data.ctmeters_phases[cttype][phase]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -22,19 +22,23 @@ import voluptuous as vol
 | 
			
		||||
 | 
			
		||||
from homeassistant.components import zeroconf
 | 
			
		||||
from homeassistant.config_entries import (
 | 
			
		||||
    SOURCE_ESPHOME,
 | 
			
		||||
    SOURCE_IGNORE,
 | 
			
		||||
    SOURCE_REAUTH,
 | 
			
		||||
    SOURCE_RECONFIGURE,
 | 
			
		||||
    ConfigEntry,
 | 
			
		||||
    ConfigFlow,
 | 
			
		||||
    ConfigFlowResult,
 | 
			
		||||
    FlowType,
 | 
			
		||||
    OptionsFlow,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
 | 
			
		||||
from homeassistant.core import callback
 | 
			
		||||
from homeassistant.data_entry_flow import AbortFlow
 | 
			
		||||
from homeassistant.data_entry_flow import AbortFlow, FlowResultType
 | 
			
		||||
from homeassistant.helpers import discovery_flow
 | 
			
		||||
from homeassistant.helpers.device_registry import format_mac
 | 
			
		||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
 | 
			
		||||
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
 | 
			
		||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
 | 
			
		||||
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
 | 
			
		||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
 | 
			
		||||
@@ -75,6 +79,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
    def __init__(self) -> None:
 | 
			
		||||
        """Initialize flow."""
 | 
			
		||||
        self._host: str | None = None
 | 
			
		||||
        self._connected_address: str | None = None
 | 
			
		||||
        self.__name: str | None = None
 | 
			
		||||
        self._port: int | None = None
 | 
			
		||||
        self._password: str | None = None
 | 
			
		||||
@@ -498,18 +503,55 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
        await self.hass.config_entries.async_remove(
 | 
			
		||||
            self._entry_with_name_conflict.entry_id
 | 
			
		||||
        )
 | 
			
		||||
        return self._async_create_entry()
 | 
			
		||||
        return await self._async_create_entry()
 | 
			
		||||
 | 
			
		||||
    @callback
 | 
			
		||||
    def _async_create_entry(self) -> ConfigFlowResult:
 | 
			
		||||
    async def _async_create_entry(self) -> ConfigFlowResult:
 | 
			
		||||
        """Create the config entry."""
 | 
			
		||||
        assert self._name is not None
 | 
			
		||||
        assert self._device_info is not None
 | 
			
		||||
 | 
			
		||||
        # Check if Z-Wave capabilities are present and start discovery flow
 | 
			
		||||
        next_flow_id: str | None = None
 | 
			
		||||
        if self._device_info.zwave_proxy_feature_flags:
 | 
			
		||||
            assert self._connected_address is not None
 | 
			
		||||
            assert self._port is not None
 | 
			
		||||
 | 
			
		||||
            # Start Z-Wave discovery flow and get the flow ID
 | 
			
		||||
            zwave_result = await self.hass.config_entries.flow.async_init(
 | 
			
		||||
                "zwave_js",
 | 
			
		||||
                context={
 | 
			
		||||
                    "source": SOURCE_ESPHOME,
 | 
			
		||||
                    "discovery_key": discovery_flow.DiscoveryKey(
 | 
			
		||||
                        domain=DOMAIN,
 | 
			
		||||
                        key=self._device_info.mac_address,
 | 
			
		||||
                        version=1,
 | 
			
		||||
                    ),
 | 
			
		||||
                },
 | 
			
		||||
                data=ESPHomeServiceInfo(
 | 
			
		||||
                    name=self._device_info.name,
 | 
			
		||||
                    zwave_home_id=self._device_info.zwave_home_id or None,
 | 
			
		||||
                    ip_address=self._connected_address,
 | 
			
		||||
                    port=self._port,
 | 
			
		||||
                    noise_psk=self._noise_psk,
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
            if zwave_result["type"] in (
 | 
			
		||||
                FlowResultType.ABORT,
 | 
			
		||||
                FlowResultType.CREATE_ENTRY,
 | 
			
		||||
            ):
 | 
			
		||||
                _LOGGER.debug(
 | 
			
		||||
                    "Unable to continue created Z-Wave JS config flow: %s", zwave_result
 | 
			
		||||
                )
 | 
			
		||||
            else:
 | 
			
		||||
                next_flow_id = zwave_result["flow_id"]
 | 
			
		||||
 | 
			
		||||
        return self.async_create_entry(
 | 
			
		||||
            title=self._name,
 | 
			
		||||
            data=self._async_make_config_data(),
 | 
			
		||||
            options={
 | 
			
		||||
                CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
 | 
			
		||||
            },
 | 
			
		||||
            next_flow=(FlowType.CONFIG_FLOW, next_flow_id) if next_flow_id else None,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @callback
 | 
			
		||||
@@ -556,7 +598,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
            if entry.data.get(CONF_DEVICE_NAME) == self._device_name:
 | 
			
		||||
                self._entry_with_name_conflict = entry
 | 
			
		||||
                return await self.async_step_name_conflict()
 | 
			
		||||
        return self._async_create_entry()
 | 
			
		||||
        return await self._async_create_entry()
 | 
			
		||||
 | 
			
		||||
    async def _async_reauth_validated_connection(self) -> ConfigFlowResult:
 | 
			
		||||
        """Handle reauth validated connection."""
 | 
			
		||||
@@ -703,6 +745,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
        try:
 | 
			
		||||
            await cli.connect()
 | 
			
		||||
            self._device_info = await cli.device_info()
 | 
			
		||||
            self._connected_address = cli.connected_address
 | 
			
		||||
        except InvalidAuthAPIError:
 | 
			
		||||
            return ERROR_INVALID_PASSWORD_AUTH
 | 
			
		||||
        except RequiresEncryptionAPIError:
 | 
			
		||||
 
 | 
			
		||||
@@ -17,9 +17,9 @@
 | 
			
		||||
  "mqtt": ["esphome/discover/#"],
 | 
			
		||||
  "quality_scale": "platinum",
 | 
			
		||||
  "requirements": [
 | 
			
		||||
    "aioesphomeapi==41.11.0",
 | 
			
		||||
    "aioesphomeapi==41.13.0",
 | 
			
		||||
    "esphome-dashboard-api==1.3.0",
 | 
			
		||||
    "bleak-esphome==3.3.0"
 | 
			
		||||
    "bleak-esphome==3.4.0"
 | 
			
		||||
  ],
 | 
			
		||||
  "zeroconf": ["_esphomelib._tcp.local."]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
    """Set up Filter from a config entry."""
 | 
			
		||||
 | 
			
		||||
    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 | 
			
		||||
    entry.async_on_unload(entry.add_update_listener(update_listener))
 | 
			
		||||
 | 
			
		||||
    return True
 | 
			
		||||
 | 
			
		||||
@@ -18,8 +17,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
    """Unload Filter config entry."""
 | 
			
		||||
    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
 | 
			
		||||
    """Handle options update."""
 | 
			
		||||
    await hass.config_entries.async_reload(entry.entry_id)
 | 
			
		||||
 
 | 
			
		||||
@@ -246,6 +246,7 @@ class FilterConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
 | 
			
		||||
 | 
			
		||||
    config_flow = CONFIG_FLOW
 | 
			
		||||
    options_flow = OPTIONS_FLOW
 | 
			
		||||
    options_flow_reloads = True
 | 
			
		||||
 | 
			
		||||
    def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
 | 
			
		||||
        """Return config entry title."""
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										27
									
								
								homeassistant/components/firefly_iii/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								homeassistant/components/firefly_iii/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
"""The Firefly III integration."""
 | 
			
		||||
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from homeassistant.const import Platform
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
 | 
			
		||||
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
 | 
			
		||||
 | 
			
		||||
_PLATFORMS: list[Platform] = [Platform.SENSOR]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_setup_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
 | 
			
		||||
    """Set up Firefly III from a config entry."""
 | 
			
		||||
 | 
			
		||||
    coordinator = FireflyDataUpdateCoordinator(hass, entry)
 | 
			
		||||
    await coordinator.async_config_entry_first_refresh()
 | 
			
		||||
 | 
			
		||||
    entry.runtime_data = coordinator
 | 
			
		||||
    await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
 | 
			
		||||
 | 
			
		||||
    return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_unload_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
 | 
			
		||||
    """Unload a config entry."""
 | 
			
		||||
    return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
 | 
			
		||||
							
								
								
									
										140
									
								
								homeassistant/components/firefly_iii/config_flow.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								homeassistant/components/firefly_iii/config_flow.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,140 @@
 | 
			
		||||
"""Config flow for the Firefly III integration."""
 | 
			
		||||
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from collections.abc import Mapping
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from pyfirefly import (
 | 
			
		||||
    Firefly,
 | 
			
		||||
    FireflyAuthenticationError,
 | 
			
		||||
    FireflyConnectionError,
 | 
			
		||||
    FireflyTimeoutError,
 | 
			
		||||
)
 | 
			
		||||
import voluptuous as vol
 | 
			
		||||
 | 
			
		||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
 | 
			
		||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.exceptions import HomeAssistantError
 | 
			
		||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
 | 
			
		||||
 | 
			
		||||
from .const import DOMAIN
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
STEP_USER_DATA_SCHEMA = vol.Schema(
 | 
			
		||||
    {
 | 
			
		||||
        vol.Required(CONF_URL): str,
 | 
			
		||||
        vol.Optional(CONF_VERIFY_SSL, default=True): bool,
 | 
			
		||||
        vol.Required(CONF_API_KEY): str,
 | 
			
		||||
    }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
 | 
			
		||||
    """Validate the user input allows us to connect."""
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        client = Firefly(
 | 
			
		||||
            api_url=data[CONF_URL],
 | 
			
		||||
            api_key=data[CONF_API_KEY],
 | 
			
		||||
            session=async_get_clientsession(hass),
 | 
			
		||||
        )
 | 
			
		||||
        await client.get_about()
 | 
			
		||||
    except FireflyAuthenticationError:
 | 
			
		||||
        raise InvalidAuth from None
 | 
			
		||||
    except FireflyConnectionError as err:
 | 
			
		||||
        raise CannotConnect from err
 | 
			
		||||
    except FireflyTimeoutError as err:
 | 
			
		||||
        raise FireflyClientTimeout from err
 | 
			
		||||
 | 
			
		||||
    return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
    """Handle a config flow for Firefly III."""
 | 
			
		||||
 | 
			
		||||
    VERSION = 1
 | 
			
		||||
 | 
			
		||||
    async def async_step_user(
 | 
			
		||||
        self, user_input: dict[str, Any] | None = None
 | 
			
		||||
    ) -> ConfigFlowResult:
 | 
			
		||||
        """Handle the initial step."""
 | 
			
		||||
        errors: dict[str, str] = {}
 | 
			
		||||
        if user_input is not None:
 | 
			
		||||
            self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
 | 
			
		||||
            try:
 | 
			
		||||
                await _validate_input(self.hass, user_input)
 | 
			
		||||
            except CannotConnect:
 | 
			
		||||
                errors["base"] = "cannot_connect"
 | 
			
		||||
            except InvalidAuth:
 | 
			
		||||
                errors["base"] = "invalid_auth"
 | 
			
		||||
            except FireflyClientTimeout:
 | 
			
		||||
                errors["base"] = "timeout_connect"
 | 
			
		||||
            except Exception:
 | 
			
		||||
                _LOGGER.exception("Unexpected exception")
 | 
			
		||||
                errors["base"] = "unknown"
 | 
			
		||||
            else:
 | 
			
		||||
                return self.async_create_entry(
 | 
			
		||||
                    title=user_input[CONF_URL], data=user_input
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        return self.async_show_form(
 | 
			
		||||
            step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    async def async_step_reauth(
 | 
			
		||||
        self, entry_data: Mapping[str, Any]
 | 
			
		||||
    ) -> ConfigFlowResult:
 | 
			
		||||
        """Perform reauth when Firefly III API authentication fails."""
 | 
			
		||||
        return await self.async_step_reauth_confirm()
 | 
			
		||||
 | 
			
		||||
    async def async_step_reauth_confirm(
 | 
			
		||||
        self, user_input: dict[str, Any] | None = None
 | 
			
		||||
    ) -> ConfigFlowResult:
 | 
			
		||||
        """Handle reauth: ask for a new API key and validate."""
 | 
			
		||||
        errors: dict[str, str] = {}
 | 
			
		||||
        reauth_entry = self._get_reauth_entry()
 | 
			
		||||
        if user_input is not None:
 | 
			
		||||
            try:
 | 
			
		||||
                await _validate_input(
 | 
			
		||||
                    self.hass,
 | 
			
		||||
                    data={
 | 
			
		||||
                        **reauth_entry.data,
 | 
			
		||||
                        CONF_API_KEY: user_input[CONF_API_KEY],
 | 
			
		||||
                    },
 | 
			
		||||
                )
 | 
			
		||||
            except CannotConnect:
 | 
			
		||||
                errors["base"] = "cannot_connect"
 | 
			
		||||
            except InvalidAuth:
 | 
			
		||||
                errors["base"] = "invalid_auth"
 | 
			
		||||
            except FireflyClientTimeout:
 | 
			
		||||
                errors["base"] = "timeout_connect"
 | 
			
		||||
            except Exception:
 | 
			
		||||
                _LOGGER.exception("Unexpected exception")
 | 
			
		||||
                errors["base"] = "unknown"
 | 
			
		||||
            else:
 | 
			
		||||
                return self.async_update_reload_and_abort(
 | 
			
		||||
                    reauth_entry,
 | 
			
		||||
                    data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        return self.async_show_form(
 | 
			
		||||
            step_id="reauth_confirm",
 | 
			
		||||
            data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
 | 
			
		||||
            errors=errors,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CannotConnect(HomeAssistantError):
 | 
			
		||||
    """Error to indicate we cannot connect."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class InvalidAuth(HomeAssistantError):
 | 
			
		||||
    """Error to indicate there is invalid auth."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FireflyClientTimeout(HomeAssistantError):
 | 
			
		||||
    """Error to indicate a timeout occurred."""
 | 
			
		||||
							
								
								
									
										6
									
								
								homeassistant/components/firefly_iii/const.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								homeassistant/components/firefly_iii/const.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
"""Constants for the Firefly III integration."""
 | 
			
		||||
 | 
			
		||||
DOMAIN = "firefly_iii"
 | 
			
		||||
 | 
			
		||||
MANUFACTURER = "Firefly III"
 | 
			
		||||
NAME = "Firefly III"
 | 
			
		||||
							
								
								
									
										137
									
								
								homeassistant/components/firefly_iii/coordinator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								homeassistant/components/firefly_iii/coordinator.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,137 @@
 | 
			
		||||
"""Data Update Coordinator for Firefly III integration."""
 | 
			
		||||
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
from aiohttp import CookieJar
 | 
			
		||||
from pyfirefly import (
 | 
			
		||||
    Firefly,
 | 
			
		||||
    FireflyAuthenticationError,
 | 
			
		||||
    FireflyConnectionError,
 | 
			
		||||
    FireflyTimeoutError,
 | 
			
		||||
)
 | 
			
		||||
from pyfirefly.models import Account, Bill, Budget, Category, Currency
 | 
			
		||||
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
 | 
			
		||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
 | 
			
		||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
 | 
			
		||||
 | 
			
		||||
from .const import DOMAIN
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
type FireflyConfigEntry = ConfigEntry[FireflyDataUpdateCoordinator]
 | 
			
		||||
 | 
			
		||||
DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class FireflyCoordinatorData:
 | 
			
		||||
    """Data structure for Firefly III coordinator data."""
 | 
			
		||||
 | 
			
		||||
    accounts: list[Account]
 | 
			
		||||
    categories: list[Category]
 | 
			
		||||
    category_details: list[Category]
 | 
			
		||||
    budgets: list[Budget]
 | 
			
		||||
    bills: list[Bill]
 | 
			
		||||
    primary_currency: Currency
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]):
 | 
			
		||||
    """Coordinator to manage data updates for Firefly III integration."""
 | 
			
		||||
 | 
			
		||||
    config_entry: FireflyConfigEntry
 | 
			
		||||
 | 
			
		||||
    def __init__(self, hass: HomeAssistant, config_entry: FireflyConfigEntry) -> None:
 | 
			
		||||
        """Initialize the coordinator."""
 | 
			
		||||
        super().__init__(
 | 
			
		||||
            hass,
 | 
			
		||||
            _LOGGER,
 | 
			
		||||
            config_entry=config_entry,
 | 
			
		||||
            name=DOMAIN,
 | 
			
		||||
            update_interval=DEFAULT_SCAN_INTERVAL,
 | 
			
		||||
        )
 | 
			
		||||
        self.firefly = Firefly(
 | 
			
		||||
            api_url=self.config_entry.data[CONF_URL],
 | 
			
		||||
            api_key=self.config_entry.data[CONF_API_KEY],
 | 
			
		||||
            session=async_create_clientsession(
 | 
			
		||||
                self.hass,
 | 
			
		||||
                self.config_entry.data[CONF_VERIFY_SSL],
 | 
			
		||||
                cookie_jar=CookieJar(unsafe=True),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    async def _async_setup(self) -> None:
 | 
			
		||||
        """Set up the coordinator."""
 | 
			
		||||
        try:
 | 
			
		||||
            await self.firefly.get_about()
 | 
			
		||||
        except FireflyAuthenticationError as err:
 | 
			
		||||
            raise ConfigEntryAuthFailed(
 | 
			
		||||
                translation_domain=DOMAIN,
 | 
			
		||||
                translation_key="invalid_auth",
 | 
			
		||||
                translation_placeholders={"error": repr(err)},
 | 
			
		||||
            ) from err
 | 
			
		||||
        except FireflyConnectionError as err:
 | 
			
		||||
            raise ConfigEntryNotReady(
 | 
			
		||||
                translation_domain=DOMAIN,
 | 
			
		||||
                translation_key="cannot_connect",
 | 
			
		||||
                translation_placeholders={"error": repr(err)},
 | 
			
		||||
            ) from err
 | 
			
		||||
        except FireflyTimeoutError as err:
 | 
			
		||||
            raise ConfigEntryNotReady(
 | 
			
		||||
                translation_domain=DOMAIN,
 | 
			
		||||
                translation_key="timeout_connect",
 | 
			
		||||
                translation_placeholders={"error": repr(err)},
 | 
			
		||||
            ) from err
 | 
			
		||||
 | 
			
		||||
    async def _async_update_data(self) -> FireflyCoordinatorData:
 | 
			
		||||
        """Fetch data from Firefly III API."""
 | 
			
		||||
        now = datetime.now()
 | 
			
		||||
        start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
 | 
			
		||||
        end_date = now
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            accounts = await self.firefly.get_accounts()
 | 
			
		||||
            categories = await self.firefly.get_categories()
 | 
			
		||||
            category_details = [
 | 
			
		||||
                await self.firefly.get_category(
 | 
			
		||||
                    category_id=int(category.id), start=start_date, end=end_date
 | 
			
		||||
                )
 | 
			
		||||
                for category in categories
 | 
			
		||||
            ]
 | 
			
		||||
            primary_currency = await self.firefly.get_currency_primary()
 | 
			
		||||
            budgets = await self.firefly.get_budgets()
 | 
			
		||||
            bills = await self.firefly.get_bills()
 | 
			
		||||
        except FireflyAuthenticationError as err:
 | 
			
		||||
            raise ConfigEntryAuthFailed(
 | 
			
		||||
                translation_domain=DOMAIN,
 | 
			
		||||
                translation_key="invalid_auth",
 | 
			
		||||
                translation_placeholders={"error": repr(err)},
 | 
			
		||||
            ) from err
 | 
			
		||||
        except FireflyConnectionError as err:
 | 
			
		||||
            raise UpdateFailed(
 | 
			
		||||
                translation_domain=DOMAIN,
 | 
			
		||||
                translation_key="cannot_connect",
 | 
			
		||||
                translation_placeholders={"error": repr(err)},
 | 
			
		||||
            ) from err
 | 
			
		||||
        except FireflyTimeoutError as err:
 | 
			
		||||
            raise UpdateFailed(
 | 
			
		||||
                translation_domain=DOMAIN,
 | 
			
		||||
                translation_key="timeout_connect",
 | 
			
		||||
                translation_placeholders={"error": repr(err)},
 | 
			
		||||
            ) from err
 | 
			
		||||
 | 
			
		||||
        return FireflyCoordinatorData(
 | 
			
		||||
            accounts=accounts,
 | 
			
		||||
            categories=categories,
 | 
			
		||||
            category_details=category_details,
 | 
			
		||||
            budgets=budgets,
 | 
			
		||||
            bills=bills,
 | 
			
		||||
            primary_currency=primary_currency,
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										40
									
								
								homeassistant/components/firefly_iii/entity.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								homeassistant/components/firefly_iii/entity.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
"""Base entity for Firefly III integration."""
 | 
			
		||||
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from yarl import URL
 | 
			
		||||
 | 
			
		||||
from homeassistant.const import CONF_URL
 | 
			
		||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
 | 
			
		||||
from homeassistant.helpers.entity import EntityDescription
 | 
			
		||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
 | 
			
		||||
 | 
			
		||||
from .const import DOMAIN, MANUFACTURER
 | 
			
		||||
from .coordinator import FireflyDataUpdateCoordinator
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FireflyBaseEntity(CoordinatorEntity[FireflyDataUpdateCoordinator]):
 | 
			
		||||
    """Base class for Firefly III entity."""
 | 
			
		||||
 | 
			
		||||
    _attr_has_entity_name = True
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        coordinator: FireflyDataUpdateCoordinator,
 | 
			
		||||
        entity_description: EntityDescription,
 | 
			
		||||
    ) -> None:
 | 
			
		||||
        """Initialize a Firefly entity."""
 | 
			
		||||
        super().__init__(coordinator)
 | 
			
		||||
 | 
			
		||||
        self.entity_description = entity_description
 | 
			
		||||
        self._attr_device_info = DeviceInfo(
 | 
			
		||||
            entry_type=DeviceEntryType.SERVICE,
 | 
			
		||||
            manufacturer=MANUFACTURER,
 | 
			
		||||
            configuration_url=URL(coordinator.config_entry.data[CONF_URL]),
 | 
			
		||||
            identifiers={
 | 
			
		||||
                (
 | 
			
		||||
                    DOMAIN,
 | 
			
		||||
                    f"{coordinator.config_entry.entry_id}_{self.entity_description.key}",
 | 
			
		||||
                )
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										18
									
								
								homeassistant/components/firefly_iii/icons.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								homeassistant/components/firefly_iii/icons.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
{
 | 
			
		||||
  "entity": {
 | 
			
		||||
    "sensor": {
 | 
			
		||||
      "account_type": {
 | 
			
		||||
        "default": "mdi:bank",
 | 
			
		||||
        "state": {
 | 
			
		||||
          "expense": "mdi:cash-minus",
 | 
			
		||||
          "revenue": "mdi:cash-plus",
 | 
			
		||||
          "asset": "mdi:account-cash",
 | 
			
		||||
          "liability": "mdi:hand-coin"
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "category": {
 | 
			
		||||
        "default": "mdi:label"
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								homeassistant/components/firefly_iii/manifest.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								homeassistant/components/firefly_iii/manifest.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
{
 | 
			
		||||
  "domain": "firefly_iii",
 | 
			
		||||
  "name": "Firefly III",
 | 
			
		||||
  "codeowners": ["@erwindouna"],
 | 
			
		||||
  "config_flow": true,
 | 
			
		||||
  "documentation": "https://www.home-assistant.io/integrations/firefly_iii",
 | 
			
		||||
  "iot_class": "local_polling",
 | 
			
		||||
  "quality_scale": "bronze",
 | 
			
		||||
  "requirements": ["pyfirefly==0.1.6"]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										68
									
								
								homeassistant/components/firefly_iii/quality_scale.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								homeassistant/components/firefly_iii/quality_scale.yaml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,68 @@
 | 
			
		||||
rules:
 | 
			
		||||
  # Bronze
 | 
			
		||||
  action-setup: done
 | 
			
		||||
  appropriate-polling: done
 | 
			
		||||
  brands: done
 | 
			
		||||
  common-modules: done
 | 
			
		||||
  config-flow-test-coverage: done
 | 
			
		||||
  config-flow: done
 | 
			
		||||
  dependency-transparency: done
 | 
			
		||||
  docs-actions: done
 | 
			
		||||
  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: |
 | 
			
		||||
      No custom actions are defined.
 | 
			
		||||
  config-entry-unloading: done
 | 
			
		||||
  docs-configuration-parameters: done
 | 
			
		||||
  docs-installation-parameters: done
 | 
			
		||||
  entity-unavailable: done
 | 
			
		||||
  integration-owner: done
 | 
			
		||||
  log-when-unavailable: done
 | 
			
		||||
  parallel-updates:
 | 
			
		||||
    status: exempt
 | 
			
		||||
    comment: |
 | 
			
		||||
      No explicit parallel updates are defined.
 | 
			
		||||
  reauthentication-flow:
 | 
			
		||||
    status: todo
 | 
			
		||||
    comment: |
 | 
			
		||||
      No reauthentication flow is defined. It will be done in a next iteration.
 | 
			
		||||
  test-coverage: done
 | 
			
		||||
  # Gold
 | 
			
		||||
  devices: done
 | 
			
		||||
  diagnostics: todo
 | 
			
		||||
  discovery-update-info: todo
 | 
			
		||||
  discovery: todo
 | 
			
		||||
  docs-data-update: todo
 | 
			
		||||
  docs-examples: todo
 | 
			
		||||
  docs-known-limitations: todo
 | 
			
		||||
  docs-supported-devices: todo
 | 
			
		||||
  docs-supported-functions: todo
 | 
			
		||||
  docs-troubleshooting: todo
 | 
			
		||||
  docs-use-cases: todo
 | 
			
		||||
  dynamic-devices: todo
 | 
			
		||||
  entity-category: todo
 | 
			
		||||
  entity-device-class: todo
 | 
			
		||||
  entity-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: done
 | 
			
		||||
							
								
								
									
										133
									
								
								homeassistant/components/firefly_iii/sensor.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								homeassistant/components/firefly_iii/sensor.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,133 @@
 | 
			
		||||
"""Sensor platform for Firefly III integration."""
 | 
			
		||||
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from pyfirefly.models import Account, Category
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.sensor import (
 | 
			
		||||
    SensorEntity,
 | 
			
		||||
    SensorEntityDescription,
 | 
			
		||||
    SensorStateClass,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.components.sensor.const import SensorDeviceClass
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
 | 
			
		||||
 | 
			
		||||
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
 | 
			
		||||
from .entity import FireflyBaseEntity
 | 
			
		||||
 | 
			
		||||
ACCOUNT_SENSORS: tuple[SensorEntityDescription, ...] = (
 | 
			
		||||
    SensorEntityDescription(
 | 
			
		||||
        key="account_type",
 | 
			
		||||
        translation_key="account",
 | 
			
		||||
        device_class=SensorDeviceClass.MONETARY,
 | 
			
		||||
        state_class=SensorStateClass.TOTAL,
 | 
			
		||||
    ),
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
CATEGORY_SENSORS: tuple[SensorEntityDescription, ...] = (
 | 
			
		||||
    SensorEntityDescription(
 | 
			
		||||
        key="category",
 | 
			
		||||
        translation_key="category",
 | 
			
		||||
        device_class=SensorDeviceClass.MONETARY,
 | 
			
		||||
        state_class=SensorStateClass.TOTAL,
 | 
			
		||||
    ),
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_setup_entry(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    entry: FireflyConfigEntry,
 | 
			
		||||
    async_add_entities: AddConfigEntryEntitiesCallback,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Set up the Firefly III sensor platform."""
 | 
			
		||||
    coordinator = entry.runtime_data
 | 
			
		||||
    entities: list[SensorEntity] = [
 | 
			
		||||
        FireflyAccountEntity(
 | 
			
		||||
            coordinator=coordinator,
 | 
			
		||||
            entity_description=description,
 | 
			
		||||
            account=account,
 | 
			
		||||
        )
 | 
			
		||||
        for account in coordinator.data.accounts
 | 
			
		||||
        for description in ACCOUNT_SENSORS
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    entities.extend(
 | 
			
		||||
        FireflyCategoryEntity(
 | 
			
		||||
            coordinator=coordinator,
 | 
			
		||||
            entity_description=description,
 | 
			
		||||
            category=category,
 | 
			
		||||
        )
 | 
			
		||||
        for category in coordinator.data.category_details
 | 
			
		||||
        for description in CATEGORY_SENSORS
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    async_add_entities(entities)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FireflyAccountEntity(FireflyBaseEntity, SensorEntity):
 | 
			
		||||
    """Entity for Firefly III account."""
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        coordinator: FireflyDataUpdateCoordinator,
 | 
			
		||||
        entity_description: SensorEntityDescription,
 | 
			
		||||
        account: Account,
 | 
			
		||||
    ) -> None:
 | 
			
		||||
        """Initialize Firefly account entity."""
 | 
			
		||||
        super().__init__(coordinator, entity_description)
 | 
			
		||||
        self._account = account
 | 
			
		||||
        self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{account.id}"
 | 
			
		||||
        self._attr_name = account.attributes.name
 | 
			
		||||
        self._attr_native_unit_of_measurement = (
 | 
			
		||||
            coordinator.data.primary_currency.attributes.code
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Account type state doesn't go well with the icons.json. Need to fix it.
 | 
			
		||||
        if account.attributes.type == "expense":
 | 
			
		||||
            self._attr_icon = "mdi:cash-minus"
 | 
			
		||||
        elif account.attributes.type == "asset":
 | 
			
		||||
            self._attr_icon = "mdi:account-cash"
 | 
			
		||||
        elif account.attributes.type == "revenue":
 | 
			
		||||
            self._attr_icon = "mdi:cash-plus"
 | 
			
		||||
        elif account.attributes.type == "liability":
 | 
			
		||||
            self._attr_icon = "mdi:hand-coin"
 | 
			
		||||
        else:
 | 
			
		||||
            self._attr_icon = "mdi:bank"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def native_value(self) -> str | None:
 | 
			
		||||
        """Return the state of the sensor."""
 | 
			
		||||
        return self._account.attributes.current_balance
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity):
 | 
			
		||||
    """Entity for Firefly III category."""
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        coordinator: FireflyDataUpdateCoordinator,
 | 
			
		||||
        entity_description: SensorEntityDescription,
 | 
			
		||||
        category: Category,
 | 
			
		||||
    ) -> None:
 | 
			
		||||
        """Initialize Firefly category entity."""
 | 
			
		||||
        super().__init__(coordinator, entity_description)
 | 
			
		||||
        self._category = category
 | 
			
		||||
        self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{category.id}"
 | 
			
		||||
        self._attr_name = category.attributes.name
 | 
			
		||||
        self._attr_native_unit_of_measurement = (
 | 
			
		||||
            coordinator.data.primary_currency.attributes.code
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def native_value(self) -> float | None:
 | 
			
		||||
        """Return the state of the sensor."""
 | 
			
		||||
        spent_items = self._category.attributes.spent or []
 | 
			
		||||
        earned_items = self._category.attributes.earned or []
 | 
			
		||||
 | 
			
		||||
        spent = sum(float(item.sum) for item in spent_items if item.sum is not None)
 | 
			
		||||
        earned = sum(float(item.sum) for item in earned_items if item.sum is not None)
 | 
			
		||||
 | 
			
		||||
        if spent == 0 and earned == 0:
 | 
			
		||||
            return None
 | 
			
		||||
        return spent + earned
 | 
			
		||||
							
								
								
									
										49
									
								
								homeassistant/components/firefly_iii/strings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								homeassistant/components/firefly_iii/strings.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
			
		||||
{
 | 
			
		||||
  "config": {
 | 
			
		||||
    "step": {
 | 
			
		||||
      "user": {
 | 
			
		||||
        "data": {
 | 
			
		||||
          "url": "[%key:common::config_flow::data::url%]",
 | 
			
		||||
          "api_key": "[%key:common::config_flow::data::api_key%]",
 | 
			
		||||
          "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
 | 
			
		||||
        },
 | 
			
		||||
        "data_description": {
 | 
			
		||||
          "url": "[%key:common::config_flow::data::url%]",
 | 
			
		||||
          "api_key": "The API key for authenticating with Firefly",
 | 
			
		||||
          "verify_ssl": "Verify the SSL certificate of the Firefly instance"
 | 
			
		||||
        },
 | 
			
		||||
        "description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
 | 
			
		||||
      },
 | 
			
		||||
      "reauth_confirm": {
 | 
			
		||||
        "data": {
 | 
			
		||||
          "api_key": "[%key:common::config_flow::data::api_key%]"
 | 
			
		||||
        },
 | 
			
		||||
        "data_description": {
 | 
			
		||||
          "api_key": "The new API access token for authenticating with Firefly III"
 | 
			
		||||
        },
 | 
			
		||||
        "description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "error": {
 | 
			
		||||
      "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
 | 
			
		||||
      "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
 | 
			
		||||
      "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
 | 
			
		||||
      "unknown": "[%key:common::config_flow::error::unknown%]"
 | 
			
		||||
    },
 | 
			
		||||
    "abort": {
 | 
			
		||||
      "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
 | 
			
		||||
      "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "exceptions": {
 | 
			
		||||
    "cannot_connect": {
 | 
			
		||||
      "message": "An error occurred while trying to connect to the Firefly instance: {error}"
 | 
			
		||||
    },
 | 
			
		||||
    "invalid_auth": {
 | 
			
		||||
      "message": "An error occurred while trying to authenticate: {error}"
 | 
			
		||||
    },
 | 
			
		||||
    "timeout_connect": {
 | 
			
		||||
      "message": "A timeout occurred while trying to connect to the Firefly instance: {error}"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -67,7 +67,7 @@ def suitable_nextchange_time(device: FritzhomeDevice) -> bool:
 | 
			
		||||
 | 
			
		||||
def suitable_temperature(device: FritzhomeDevice) -> bool:
 | 
			
		||||
    """Check suitablity for temperature sensor."""
 | 
			
		||||
    return device.has_temperature_sensor and not device.has_thermostat
 | 
			
		||||
    return bool(device.has_temperature_sensor)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def entity_category_temperature(device: FritzhomeDevice) -> EntityCategory | None:
 | 
			
		||||
 
 | 
			
		||||
@@ -452,6 +452,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
 | 
			
		||||
 | 
			
		||||
    hass.http.app.router.register_resource(IndexView(repo_path, hass))
 | 
			
		||||
 | 
			
		||||
    async_register_built_in_panel(hass, "light")
 | 
			
		||||
    async_register_built_in_panel(hass, "security")
 | 
			
		||||
    async_register_built_in_panel(hass, "climate")
 | 
			
		||||
 | 
			
		||||
    async_register_built_in_panel(hass, "profile")
 | 
			
		||||
 | 
			
		||||
    async_register_built_in_panel(
 | 
			
		||||
@@ -459,7 +463,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
 | 
			
		||||
        "developer-tools",
 | 
			
		||||
        require_admin=True,
 | 
			
		||||
        sidebar_title="developer_tools",
 | 
			
		||||
        sidebar_icon="hass:hammer",
 | 
			
		||||
        sidebar_icon="mdi:hammer",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    @callback
 | 
			
		||||
 
 | 
			
		||||
@@ -20,5 +20,5 @@
 | 
			
		||||
  "documentation": "https://www.home-assistant.io/integrations/frontend",
 | 
			
		||||
  "integration_type": "system",
 | 
			
		||||
  "quality_scale": "internal",
 | 
			
		||||
  "requirements": ["home-assistant-frontend==20251001.2"]
 | 
			
		||||
  "requirements": ["home-assistant-frontend==20251001.0"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,10 @@
 | 
			
		||||
load_url:
 | 
			
		||||
  target:
 | 
			
		||||
    device:
 | 
			
		||||
      integration: fully_kiosk
 | 
			
		||||
  fields:
 | 
			
		||||
    device_id:
 | 
			
		||||
      required: true
 | 
			
		||||
      selector:
 | 
			
		||||
        device:
 | 
			
		||||
          integration: fully_kiosk
 | 
			
		||||
    url:
 | 
			
		||||
      example: "https://home-assistant.io"
 | 
			
		||||
      required: true
 | 
			
		||||
@@ -10,10 +12,12 @@ load_url:
 | 
			
		||||
        text:
 | 
			
		||||
 | 
			
		||||
set_config:
 | 
			
		||||
  target:
 | 
			
		||||
    device:
 | 
			
		||||
      integration: fully_kiosk
 | 
			
		||||
  fields:
 | 
			
		||||
    device_id:
 | 
			
		||||
      required: true
 | 
			
		||||
      selector:
 | 
			
		||||
        device:
 | 
			
		||||
          integration: fully_kiosk
 | 
			
		||||
    key:
 | 
			
		||||
      example: "motionSensitivity"
 | 
			
		||||
      required: true
 | 
			
		||||
@@ -26,12 +30,14 @@ set_config:
 | 
			
		||||
        text:
 | 
			
		||||
 | 
			
		||||
start_application:
 | 
			
		||||
  target:
 | 
			
		||||
    device:
 | 
			
		||||
      integration: fully_kiosk
 | 
			
		||||
  fields:
 | 
			
		||||
    application:
 | 
			
		||||
      example: "de.ozerov.fully"
 | 
			
		||||
      required: true
 | 
			
		||||
      selector:
 | 
			
		||||
        text:
 | 
			
		||||
    device_id:
 | 
			
		||||
      required: true
 | 
			
		||||
      selector:
 | 
			
		||||
        device:
 | 
			
		||||
          integration: fully_kiosk
 | 
			
		||||
 
 | 
			
		||||
@@ -147,6 +147,10 @@
 | 
			
		||||
      "name": "Load URL",
 | 
			
		||||
      "description": "Loads a URL on Fully Kiosk Browser.",
 | 
			
		||||
      "fields": {
 | 
			
		||||
        "device_id": {
 | 
			
		||||
          "name": "Device ID",
 | 
			
		||||
          "description": "The target device for this action."
 | 
			
		||||
        },
 | 
			
		||||
        "url": {
 | 
			
		||||
          "name": "[%key:common::config_flow::data::url%]",
 | 
			
		||||
          "description": "URL to load."
 | 
			
		||||
@@ -157,6 +161,10 @@
 | 
			
		||||
      "name": "Set configuration",
 | 
			
		||||
      "description": "Sets a configuration parameter on Fully Kiosk Browser.",
 | 
			
		||||
      "fields": {
 | 
			
		||||
        "device_id": {
 | 
			
		||||
          "name": "[%key:component::fully_kiosk::services::load_url::fields::device_id::name%]",
 | 
			
		||||
          "description": "[%key:component::fully_kiosk::services::load_url::fields::device_id::description%]"
 | 
			
		||||
        },
 | 
			
		||||
        "key": {
 | 
			
		||||
          "name": "Key",
 | 
			
		||||
          "description": "Configuration parameter to set."
 | 
			
		||||
@@ -174,6 +182,10 @@
 | 
			
		||||
        "application": {
 | 
			
		||||
          "name": "Application",
 | 
			
		||||
          "description": "Package name of the application to start."
 | 
			
		||||
        },
 | 
			
		||||
        "device_id": {
 | 
			
		||||
          "name": "[%key:component::fully_kiosk::services::load_url::fields::device_id::name%]",
 | 
			
		||||
          "description": "[%key:component::fully_kiosk::services::load_url::fields::device_id::description%]"
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -108,6 +108,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
            entry,
 | 
			
		||||
            options={**entry.options, CONF_HUMIDIFIER: source_entity_id},
 | 
			
		||||
        )
 | 
			
		||||
        hass.config_entries.async_schedule_reload(entry.entry_id)
 | 
			
		||||
 | 
			
		||||
    entry.async_on_unload(
 | 
			
		||||
        # We use async_handle_source_entity_changes to track changes to the humidifer,
 | 
			
		||||
@@ -140,6 +141,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
            entry,
 | 
			
		||||
            options={**entry.options, CONF_SENSOR: data["entity_id"]},
 | 
			
		||||
        )
 | 
			
		||||
        hass.config_entries.async_schedule_reload(entry.entry_id)
 | 
			
		||||
 | 
			
		||||
    entry.async_on_unload(
 | 
			
		||||
        async_track_entity_registry_updated_event(
 | 
			
		||||
@@ -148,7 +150,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    await hass.config_entries.async_forward_entry_setups(entry, (Platform.HUMIDIFIER,))
 | 
			
		||||
    entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
 | 
			
		||||
    return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -186,11 +187,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
 | 
			
		||||
    return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
 | 
			
		||||
    """Update listener, called when the config entry options are changed."""
 | 
			
		||||
    await hass.config_entries.async_reload(entry.entry_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
    """Unload a config entry."""
 | 
			
		||||
    return await hass.config_entries.async_unload_platforms(
 | 
			
		||||
 
 | 
			
		||||
@@ -96,6 +96,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
 | 
			
		||||
 | 
			
		||||
    config_flow = CONFIG_FLOW
 | 
			
		||||
    options_flow = OPTIONS_FLOW
 | 
			
		||||
    options_flow_reloads = True
 | 
			
		||||
 | 
			
		||||
    def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
 | 
			
		||||
        """Return config entry title."""
 | 
			
		||||
 
 | 
			
		||||
@@ -35,6 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
            entry,
 | 
			
		||||
            options={**entry.options, CONF_HEATER: source_entity_id},
 | 
			
		||||
        )
 | 
			
		||||
        hass.config_entries.async_schedule_reload(entry.entry_id)
 | 
			
		||||
 | 
			
		||||
    entry.async_on_unload(
 | 
			
		||||
        # We use async_handle_source_entity_changes to track changes to the heater, but
 | 
			
		||||
@@ -67,6 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
            entry,
 | 
			
		||||
            options={**entry.options, CONF_SENSOR: data["entity_id"]},
 | 
			
		||||
        )
 | 
			
		||||
        hass.config_entries.async_schedule_reload(entry.entry_id)
 | 
			
		||||
 | 
			
		||||
    entry.async_on_unload(
 | 
			
		||||
        async_track_entity_registry_updated_event(
 | 
			
		||||
@@ -75,7 +77,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 | 
			
		||||
    entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
 | 
			
		||||
    return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -113,11 +114,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
 | 
			
		||||
    return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
 | 
			
		||||
    """Update listener, called when the config entry options are changed."""
 | 
			
		||||
    await hass.config_entries.async_reload(entry.entry_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
    """Unload a config entry."""
 | 
			
		||||
    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 | 
			
		||||
 
 | 
			
		||||
@@ -104,6 +104,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
 | 
			
		||||
 | 
			
		||||
    config_flow = CONFIG_FLOW
 | 
			
		||||
    options_flow = OPTIONS_FLOW
 | 
			
		||||
    options_flow_reloads = True
 | 
			
		||||
 | 
			
		||||
    def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
 | 
			
		||||
        """Return config entry title."""
 | 
			
		||||
 
 | 
			
		||||
@@ -54,7 +54,7 @@ async def async_setup_entry(
 | 
			
		||||
    except aiohttp.ClientResponseError as err:
 | 
			
		||||
        if 400 <= err.status < 500:
 | 
			
		||||
            raise ConfigEntryAuthFailed(
 | 
			
		||||
                "OAuth session is not valid, reauth required"
 | 
			
		||||
                translation_domain=DOMAIN, translation_key="reauth_required"
 | 
			
		||||
            ) from err
 | 
			
		||||
        raise ConfigEntryNotReady from err
 | 
			
		||||
    except aiohttp.ClientError as err:
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,7 @@
 | 
			
		||||
        },
 | 
			
		||||
        "media_player": {
 | 
			
		||||
          "name": "Media player entity",
 | 
			
		||||
          "description": "Name(s) of media player entities to play response on."
 | 
			
		||||
          "description": "Name(s) of media player entities to play the Google Assistant's audio response on. This does not target the device for the command itself."
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
@@ -70,6 +70,9 @@
 | 
			
		||||
    },
 | 
			
		||||
    "grpc_error": {
 | 
			
		||||
      "message": "Failed to communicate with Google Assistant"
 | 
			
		||||
    },
 | 
			
		||||
    "reauth_required": {
 | 
			
		||||
      "message": "Credentials are invalid, re-authentication required"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,7 @@ from homeassistant.exceptions import (
 | 
			
		||||
from homeassistant.helpers import config_entry_oauth2_flow
 | 
			
		||||
 | 
			
		||||
_UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600
 | 
			
		||||
_UPLOAD_MAX_RETRIES = 20
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -150,6 +151,7 @@ class DriveClient:
 | 
			
		||||
            backup_metadata,
 | 
			
		||||
            open_stream,
 | 
			
		||||
            backup.size,
 | 
			
		||||
            max_retries=_UPLOAD_MAX_RETRIES,
 | 
			
		||||
            timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT),
 | 
			
		||||
        )
 | 
			
		||||
        _LOGGER.debug(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,5 @@
 | 
			
		||||
set_vacation:
 | 
			
		||||
  target:
 | 
			
		||||
    device:
 | 
			
		||||
      integration: google_mail
 | 
			
		||||
    entity:
 | 
			
		||||
      integration: google_mail
 | 
			
		||||
  fields:
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user