forked from home-assistant/core
Compare commits
571 Commits
2021.1.0b0
...
2021.2.0b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 66e045f570 | |||
| 0435586bb1 | |||
| 76c27a7d5a | |||
| d759fa3407 | |||
| 34bc5ef0cd | |||
| f4ff2708e0 | |||
| d3a92dcac6 | |||
| b575b0a700 | |||
| 7ff60601c9 | |||
| bba7c0454c | |||
| 277aa01088 | |||
| 67ee2fb822 | |||
| a56b250e31 | |||
| e169ad93c2 | |||
| fba781a535 | |||
| 88a5ff4a51 | |||
| cd1c8b78a1 | |||
| b705468b57 | |||
| 938e5ff435 | |||
| 000def5ded | |||
| 760b75a5c1 | |||
| bf819df388 | |||
| 493f3bc1ce | |||
| 18df06d6a6 | |||
| 0df1f8bfbd | |||
| 4f73105896 | |||
| 53db89e13d | |||
| 078579de69 | |||
| bbb18ffec4 | |||
| a8ad51ceb2 | |||
| 68ea62f5ef | |||
| a81a4ad44b | |||
| 4a2ad442c7 | |||
| 498f8db0d8 | |||
| 71b67ba572 | |||
| 7a746adb04 | |||
| 869bc2c4ce | |||
| b072a6c91a | |||
| 6070c7c83a | |||
| af0ca31d77 | |||
| 80e176aab3 | |||
| d1b677d0f9 | |||
| 9e5bf6b9f6 | |||
| 385bdc4058 | |||
| 4dbd2f2a6b | |||
| 800c7f84ff | |||
| 40b3ed1419 | |||
| a05f839d3d | |||
| d3daf02ef3 | |||
| b420c1ceb7 | |||
| 6aadf14bdd | |||
| 73a04e653f | |||
| ef3bdd1afc | |||
| a1662b3bb9 | |||
| 566058f701 | |||
| f14c4412b7 | |||
| 8d572af77a | |||
| b4af17e02d | |||
| fb39185420 | |||
| 211ef60d96 | |||
| 5bc4db5ef9 | |||
| 80c2efa9f2 | |||
| eea50c8ccc | |||
| 5d955eb039 | |||
| 32c6509d55 | |||
| 78b057ce02 | |||
| 1433cdaa12 | |||
| 3841f0e42d | |||
| c8ad06e58a | |||
| 34194da1b6 | |||
| 122a4e03f8 | |||
| fb2db34334 | |||
| 459236fcdd | |||
| 67b309394f | |||
| e12e2377af | |||
| 26e266181d | |||
| 491e66793e | |||
| c225f4b4ea | |||
| d99118f6ba | |||
| 890eaf840c | |||
| 06ade6129c | |||
| 6800f4b6fd | |||
| f387e833c3 | |||
| c805baf88c | |||
| 25f411ef6e | |||
| b533b91b10 | |||
| 14785660b0 | |||
| fb527814f9 | |||
| 73d2ae76a9 | |||
| ee8d88e85c | |||
| 08e7247201 | |||
| 74a44e03fa | |||
| 67fcdc5a9c | |||
| 352d0870e3 | |||
| 74efe78d0a | |||
| 712a5a098d | |||
| 568962552b | |||
| 7a39a86eb9 | |||
| 3b0a440770 | |||
| f53a83e084 | |||
| 7816eccee7 | |||
| 21d23a6f94 | |||
| 56253a6245 | |||
| daddb76e1d | |||
| 52c56fdac7 | |||
| 9ae4818a23 | |||
| 76be67fa64 | |||
| 26764a805b | |||
| baab9b9a81 | |||
| b1c2cde40b | |||
| 4739e8a207 | |||
| 893406c834 | |||
| d082be787f | |||
| 38361b134a | |||
| ab710f2154 | |||
| c16fd0a1ac | |||
| 3647d549b0 | |||
| b9a525a9a7 | |||
| 260d9f8e16 | |||
| f2a8ccdbae | |||
| 622906965d | |||
| d110d42913 | |||
| ad677b9d41 | |||
| ec47df4880 | |||
| 25469f1a07 | |||
| 11009b99bd | |||
| 094844f834 | |||
| 3de2c900f3 | |||
| 666a94a8e1 | |||
| d174c8265e | |||
| edfb8c3423 | |||
| 1c0a74f18a | |||
| 104107dd95 | |||
| bf8d17f1b5 | |||
| a7e8c62204 | |||
| c61331e8c5 | |||
| 47e34bb129 | |||
| 616328c7c4 | |||
| 34f701a69b | |||
| 12e31b9571 | |||
| dbaca51bb3 | |||
| b69d9860b6 | |||
| 4bb6911b76 | |||
| 47c0adb312 | |||
| c2900ff888 | |||
| 1e7e5220a3 | |||
| 59b0a4d060 | |||
| 0e068a5f39 | |||
| eb339b9793 | |||
| 6715eae3d7 | |||
| db7c260ffb | |||
| 7abdad4a99 | |||
| f7df00bbbd | |||
| 09f5c7f4c5 | |||
| 3b71ac2ec9 | |||
| 0930aae208 | |||
| f86beed7b0 | |||
| a0b906005d | |||
| e92c4c99d5 | |||
| 954ad854fb | |||
| 011d5208fd | |||
| e40f0bf429 | |||
| daf24dc508 | |||
| 431b143eec | |||
| 03fb73c0ae | |||
| 68e7ecb74b | |||
| 22a6e55e70 | |||
| 57fa7f926a | |||
| 18c7ae9a8b | |||
| aaf4cd4a25 | |||
| 6813454821 | |||
| 86cd7911de | |||
| 198b875a6f | |||
| 7e8d0a263c | |||
| 4aceb0dd27 | |||
| 89fc92f68a | |||
| 2bc59c1d8e | |||
| 8c573ae29c | |||
| 30ed998d83 | |||
| 148bc6081f | |||
| 8f7a4a68b2 | |||
| 38d4af1a6e | |||
| 59f178ab9e | |||
| f117ddc6fa | |||
| 3029a95410 | |||
| d733292982 | |||
| db83aea1df | |||
| 5de8639798 | |||
| b68c287ff1 | |||
| ded242a8fe | |||
| b601e7e497 | |||
| 25adc6dd4f | |||
| 1f66457a34 | |||
| 16843d9ec7 | |||
| 03711b1d97 | |||
| fd363f9c3a | |||
| 30622b5575 | |||
| bb1224d06f | |||
| daa9449f18 | |||
| e97448a201 | |||
| e8cda598ac | |||
| 2925474a5d | |||
| 9b14586568 | |||
| 7a81ff55bc | |||
| 536e835b39 | |||
| 243014bff1 | |||
| 96448c6778 | |||
| a7741be9bb | |||
| da4404e8cf | |||
| 7ff02fe8d4 | |||
| 14f5eb7305 | |||
| e46f1c0a10 | |||
| ab62a4ce39 | |||
| 0f5a3c4359 | |||
| 7a01d33814 | |||
| bf0e012d1e | |||
| d284c6369e | |||
| c03b4d8aee | |||
| ef8ee38274 | |||
| 3ae527c158 | |||
| a9a0f8938f | |||
| 52cfc06e12 | |||
| c1addde6f9 | |||
| d7a0f1e467 | |||
| f1646f4ecc | |||
| c929fbeea3 | |||
| 07c3981de7 | |||
| 852136ccfe | |||
| 94dbcc9d2b | |||
| 4928476abe | |||
| 8d3564e275 | |||
| 8de0b7b948 | |||
| adab9adbce | |||
| 5ee4479151 | |||
| c621c0fa5d | |||
| 8e0addd216 | |||
| cf9ea6f82d | |||
| b2f914823d | |||
| a1b0d6baad | |||
| ae3d038baa | |||
| a50fba4e0b | |||
| 21b9b6b2c8 | |||
| 34a9b93d41 | |||
| dcd17530cd | |||
| e828e18156 | |||
| fbc98b1291 | |||
| a42d43d054 | |||
| 28a611f3da | |||
| 41e7d960ee | |||
| b71a9b5e28 | |||
| 11cbf1152d | |||
| 1454349813 | |||
| bf4b4623aa | |||
| d4b3cf9c47 | |||
| 2de0b2aeca | |||
| 278c5193a6 | |||
| 34b90fd925 | |||
| 120304f235 | |||
| a66528c640 | |||
| 2d4576ddcf | |||
| 7d2fd4bce5 | |||
| cecb711fe5 | |||
| 3a3e5e636b | |||
| fa8ae77a6f | |||
| c8990e373d | |||
| d80ef6c5fb | |||
| 0b9687f7bd | |||
| e72e66ae6f | |||
| 3cc45697cb | |||
| 8151721fbc | |||
| 562d30319b | |||
| b6148bbbe7 | |||
| 74082194a6 | |||
| 7c7b357357 | |||
| 2044b33eb6 | |||
| 233f923cd7 | |||
| 5e01b828af | |||
| 598a0d19b1 | |||
| b3764da912 | |||
| 5677adc104 | |||
| 03b2fbd043 | |||
| cffb1458a1 | |||
| 7ce897f373 | |||
| 74c81b7c01 | |||
| 2e42a862b0 | |||
| 9dbf14188a | |||
| 071c8cc67d | |||
| b4268edd6a | |||
| e70abf5434 | |||
| 572b323ade | |||
| f91d40ac38 | |||
| 48be748058 | |||
| c2deadf994 | |||
| e1427c45f2 | |||
| b5690053a9 | |||
| 41b45c7f78 | |||
| 93c2f2bbeb | |||
| dee0f887de | |||
| a276f2d19e | |||
| 7fada806af | |||
| 2600fdfa67 | |||
| 0bd2c13e26 | |||
| 0fdda9d0f6 | |||
| 7ac208ead6 | |||
| e1989399f0 | |||
| d0216307f3 | |||
| f141f18db7 | |||
| 2294f11070 | |||
| 3771a800d3 | |||
| a1368ad3ed | |||
| f047d04882 | |||
| 3800a4feee | |||
| ab518a7755 | |||
| 4bca9596ee | |||
| 4efe6762c4 | |||
| 23a73dc5b1 | |||
| 2ac658d257 | |||
| e0c8b1aab6 | |||
| da677f7d5a | |||
| 402a0ea7da | |||
| 17cb071173 | |||
| 3ebc5d45a8 | |||
| eca6bc6a73 | |||
| 7872e6caf8 | |||
| 94417e3e14 | |||
| e05bb7e858 | |||
| 732cf47ff6 | |||
| 81c77942eb | |||
| 79d37fdf12 | |||
| de8f273bd0 | |||
| 83b210061d | |||
| 3537a7c3d5 | |||
| 938d8be0c8 | |||
| 411cc6542c | |||
| 3364e945aa | |||
| f78b02b163 | |||
| ffd9c4e410 | |||
| b9e4d5988f | |||
| 6325bc8bfe | |||
| ff3a1f2050 | |||
| ec038bc6ea | |||
| 10bc05df00 | |||
| ac60b34d17 | |||
| eebd0d333e | |||
| 82746616fa | |||
| 4e71be852a | |||
| be2aba6c52 | |||
| 8ce32d67f9 | |||
| e83ced6737 | |||
| f312b87a3f | |||
| cad2fa89ed | |||
| bade98624d | |||
| 13cdf0ba63 | |||
| f19b72ea02 | |||
| bc2c7b2d48 | |||
| e3f38942cc | |||
| d270f9515b | |||
| d60fc0de38 | |||
| 74e7f7c879 | |||
| eb5f3b282b | |||
| 38a5f25b59 | |||
| 65e3661f88 | |||
| ed4e8cdbc5 | |||
| af21893652 | |||
| e584902b8b | |||
| 54064b4010 | |||
| ab25c5a2bd | |||
| d68fdbc283 | |||
| 1402e7ae56 | |||
| 4de9f5194f | |||
| f5b389faa8 | |||
| 4b54694c5c | |||
| b450d4c135 | |||
| 707a8e62f9 | |||
| a73a82e381 | |||
| f240106189 | |||
| 248802efd5 | |||
| 75615bd92a | |||
| 9524766b07 | |||
| 21121b6e9b | |||
| 1b0e0996af | |||
| 0e4c560f38 | |||
| 5dfe8e15e3 | |||
| 5ef6f87ab9 | |||
| eabe757e20 | |||
| 8b72324ae6 | |||
| 982c42e746 | |||
| 6dd6d9b368 | |||
| 2d9eb25142 | |||
| b85efd343f | |||
| 3a88a4120e | |||
| 3569d92385 | |||
| e3c1281616 | |||
| 8fa62329a4 | |||
| 905100a189 | |||
| 793adb7f40 | |||
| d99bc99d9b | |||
| 58195c64b7 | |||
| 7c93a11aba | |||
| c457ea854c | |||
| e134c17df2 | |||
| 30189fb5d5 | |||
| e35e460e69 | |||
| 3b184ad11c | |||
| c54a0f80af | |||
| cb3b37a87a | |||
| 20e2493f68 | |||
| 0426b211f6 | |||
| caf14b78d1 | |||
| 2fb3be50ab | |||
| 751ac0b955 | |||
| 92431049e5 | |||
| 1a44a8a714 | |||
| bb1ebae5cb | |||
| 03ffeb9a02 | |||
| 57d119a7fe | |||
| 6de8824980 | |||
| 9c478e8de7 | |||
| 560e3811a3 | |||
| 9d03b56c5c | |||
| b6d323b008 | |||
| 88eac0be85 | |||
| d3d66c2e27 | |||
| 2e864ca435 | |||
| 72e6d58a99 | |||
| 1c2f88c500 | |||
| 93ae65d704 | |||
| 1a65ab0b80 | |||
| 02bfc68842 | |||
| f18880686c | |||
| c258c2653f | |||
| 587676f436 | |||
| 2ee50a4d54 | |||
| fdce5878c6 | |||
| 0b8251d9a1 | |||
| e4a84bb1c3 | |||
| 9396d9db5f | |||
| b3fda469cf | |||
| cc57dd9534 | |||
| 009663602a | |||
| 34161f3ff6 | |||
| 35edc40537 | |||
| f1c116831f | |||
| 69b5176730 | |||
| 16e1046dbc | |||
| 67eebce55a | |||
| 853420d972 | |||
| c654476e24 | |||
| addafd517f | |||
| 106252ea21 | |||
| 0c85ed1385 | |||
| 6cd18971b1 | |||
| 86154744e4 | |||
| 65e56d03bf | |||
| d315ab2cf5 | |||
| 60a1948ab0 | |||
| 2e50c1be8e | |||
| 76537305e2 | |||
| 773d95251e | |||
| 7657a5c901 | |||
| cd756f20b1 | |||
| 766f89f338 | |||
| de780c6d35 | |||
| c92353088c | |||
| c1027cace6 | |||
| e9f7e67f4c | |||
| 3a32e16f4d | |||
| f07bf6a88e | |||
| e4fcc9c692 | |||
| 5f91f14a49 | |||
| f42ce2b0d1 | |||
| 506fdc877a | |||
| f33c1332b9 | |||
| 8da79479d3 | |||
| 92e354ca38 | |||
| f771d8ff14 | |||
| a7a4875f52 | |||
| 5b67030c26 | |||
| 0cff069c98 | |||
| 34bd70aee6 | |||
| 43474762b2 | |||
| 134db3f710 | |||
| 12af87bc6e | |||
| 3c62c21991 | |||
| 805a9bcb97 | |||
| e9d2f583b6 | |||
| ad4804f38a | |||
| ec926105a0 | |||
| 2bd8ee34f4 | |||
| 10b5912901 | |||
| 1fc4284a29 | |||
| 2ed7b90027 | |||
| cc21639f00 | |||
| 3de0610909 | |||
| 067f2d0098 | |||
| 40cbe597be | |||
| a6e474c7c9 | |||
| 321c0a87ae | |||
| a2ca08905f | |||
| 508d33a220 | |||
| 65cf2fcb6f | |||
| 79aad3f07b | |||
| e781e1b26c | |||
| c60390a52a | |||
| af1d46aa6c | |||
| 6d33f6a115 | |||
| 39b9821d29 | |||
| 864546201e | |||
| 1c2e4226dc | |||
| 34a6b4deb0 | |||
| 2b1df2e63f | |||
| 610ee24bb1 | |||
| 3cd97398aa | |||
| 7d99a35547 | |||
| 470fe887a5 | |||
| 1b26f6e8e0 | |||
| 8a689a0105 | |||
| e65903822d | |||
| 335aceedfb | |||
| 2b0556520b | |||
| c7fa98211d | |||
| 661eb0338a | |||
| c4fbfc25e3 | |||
| 70d2c37131 | |||
| 5c634ac8bb | |||
| ea10f96bf7 | |||
| e2e79aba4e | |||
| 52e1aad008 | |||
| 176415b045 | |||
| 1f0a6b178e | |||
| f0e96f739b | |||
| ddfc3d6d8e | |||
| 168b3ae6af | |||
| 2f486543df | |||
| b651f63ef0 | |||
| 051f6c0e72 | |||
| c4b11322c8 | |||
| 2ef25e7414 | |||
| 7415dacec9 | |||
| 94825b3e15 | |||
| 681f76b99d | |||
| 5e0eea21d4 | |||
| 99eed915d6 | |||
| 787027958d | |||
| db6bd22fc9 | |||
| 41ebfcdc9e | |||
| 12b7b2098d | |||
| edee0682ba | |||
| 61f137c7c6 | |||
| 74b480e9d4 | |||
| f1dff973dc | |||
| fe9a254017 | |||
| 1c8fbc7e6a | |||
| cdda5900e5 | |||
| 64dd748330 | |||
| 408da3600b | |||
| 4bde0640d6 | |||
| 1428c403ba | |||
| c7bf7b32a2 | |||
| b290a8b5a1 | |||
| 687f90e164 | |||
| da66a4e933 | |||
| e2964ca878 | |||
| b1bb0d12c9 | |||
| e37bb51320 | |||
| 15a4e1e1b3 | |||
| 2e62e0661b | |||
| 16ddbb95f4 | |||
| a6c83cc46a | |||
| e2e07cf42e | |||
| 4b057101c5 |
+26
-16
@@ -29,6 +29,8 @@ omit =
|
||||
homeassistant/components/agent_dvr/camera.py
|
||||
homeassistant/components/agent_dvr/const.py
|
||||
homeassistant/components/agent_dvr/helpers.py
|
||||
homeassistant/components/airnow/__init__.py
|
||||
homeassistant/components/airnow/sensor.py
|
||||
homeassistant/components/airvisual/__init__.py
|
||||
homeassistant/components/airvisual/air_quality.py
|
||||
homeassistant/components/airvisual/sensor.py
|
||||
@@ -142,7 +144,7 @@ omit =
|
||||
homeassistant/components/co2signal/*
|
||||
homeassistant/components/coinbase/*
|
||||
homeassistant/components/comed_hourly_pricing/sensor.py
|
||||
homeassistant/components/comfoconnect/*
|
||||
homeassistant/components/comfoconnect/fan.py
|
||||
homeassistant/components/concord232/alarm_control_panel.py
|
||||
homeassistant/components/concord232/binary_sensor.py
|
||||
homeassistant/components/control4/__init__.py
|
||||
@@ -213,7 +215,11 @@ omit =
|
||||
homeassistant/components/ecobee/notify.py
|
||||
homeassistant/components/ecobee/sensor.py
|
||||
homeassistant/components/ecobee/weather.py
|
||||
homeassistant/components/econet/*
|
||||
homeassistant/components/econet/__init__.py
|
||||
homeassistant/components/econet/binary_sensor.py
|
||||
homeassistant/components/econet/const.py
|
||||
homeassistant/components/econet/sensor.py
|
||||
homeassistant/components/econet/water_heater.py
|
||||
homeassistant/components/ecovacs/*
|
||||
homeassistant/components/edl21/*
|
||||
homeassistant/components/eddystone_temperature/sensor.py
|
||||
@@ -298,8 +304,8 @@ omit =
|
||||
homeassistant/components/folder_watcher/*
|
||||
homeassistant/components/foobot/sensor.py
|
||||
homeassistant/components/fortios/device_tracker.py
|
||||
homeassistant/components/foscam/__init__.py
|
||||
homeassistant/components/foscam/camera.py
|
||||
homeassistant/components/foscam/const.py
|
||||
homeassistant/components/foursquare/*
|
||||
homeassistant/components/free_mobile/notify.py
|
||||
homeassistant/components/freebox/__init__.py
|
||||
@@ -308,6 +314,9 @@ omit =
|
||||
homeassistant/components/freebox/sensor.py
|
||||
homeassistant/components/freebox/switch.py
|
||||
homeassistant/components/fritz/device_tracker.py
|
||||
homeassistant/components/fritzbox_callmonitor/__init__.py
|
||||
homeassistant/components/fritzbox_callmonitor/const.py
|
||||
homeassistant/components/fritzbox_callmonitor/base.py
|
||||
homeassistant/components/fritzbox_callmonitor/sensor.py
|
||||
homeassistant/components/fritzbox_netmonitor/sensor.py
|
||||
homeassistant/components/fronius/sensor.py
|
||||
@@ -356,7 +365,10 @@ omit =
|
||||
homeassistant/components/hangouts/hangouts_bot.py
|
||||
homeassistant/components/hangouts/hangups_utils.py
|
||||
homeassistant/components/harman_kardon_avr/media_player.py
|
||||
homeassistant/components/harmony/*
|
||||
homeassistant/components/harmony/const.py
|
||||
homeassistant/components/harmony/data.py
|
||||
homeassistant/components/harmony/remote.py
|
||||
homeassistant/components/harmony/util.py
|
||||
homeassistant/components/haveibeenpwned/sensor.py
|
||||
homeassistant/components/hdmi_cec/*
|
||||
homeassistant/components/heatmiser/climate.py
|
||||
@@ -578,13 +590,7 @@ omit =
|
||||
homeassistant/components/neato/vacuum.py
|
||||
homeassistant/components/nederlandse_spoorwegen/sensor.py
|
||||
homeassistant/components/nello/lock.py
|
||||
homeassistant/components/nest/__init__.py
|
||||
homeassistant/components/nest/api.py
|
||||
homeassistant/components/nest/binary_sensor.py
|
||||
homeassistant/components/nest/camera.py
|
||||
homeassistant/components/nest/climate.py
|
||||
homeassistant/components/nest/legacy/*
|
||||
homeassistant/components/nest/sensor.py
|
||||
homeassistant/components/netatmo/__init__.py
|
||||
homeassistant/components/netatmo/api.py
|
||||
homeassistant/components/netatmo/camera.py
|
||||
@@ -629,6 +635,11 @@ omit =
|
||||
homeassistant/components/omnilogic/__init__.py
|
||||
homeassistant/components/omnilogic/common.py
|
||||
homeassistant/components/omnilogic/sensor.py
|
||||
homeassistant/components/ondilo_ico/__init__.py
|
||||
homeassistant/components/ondilo_ico/api.py
|
||||
homeassistant/components/ondilo_ico/const.py
|
||||
homeassistant/components/ondilo_ico/oauth_impl.py
|
||||
homeassistant/components/ondilo_ico/sensor.py
|
||||
homeassistant/components/onkyo/media_player.py
|
||||
homeassistant/components/onvif/__init__.py
|
||||
homeassistant/components/onvif/base.py
|
||||
@@ -690,8 +701,6 @@ omit =
|
||||
homeassistant/components/pjlink/media_player.py
|
||||
homeassistant/components/plaato/*
|
||||
homeassistant/components/plex/media_player.py
|
||||
homeassistant/components/plex/models.py
|
||||
homeassistant/components/plex/sensor.py
|
||||
homeassistant/components/plum_lightpad/light.py
|
||||
homeassistant/components/pocketcasts/sensor.py
|
||||
homeassistant/components/point/*
|
||||
@@ -706,7 +715,6 @@ omit =
|
||||
homeassistant/components/prowl/notify.py
|
||||
homeassistant/components/proxmoxve/*
|
||||
homeassistant/components/proxy/camera.py
|
||||
homeassistant/components/ptvsd/*
|
||||
homeassistant/components/pulseaudio_loopback/switch.py
|
||||
homeassistant/components/pushbullet/notify.py
|
||||
homeassistant/components/pushbullet/sensor.py
|
||||
@@ -790,11 +798,9 @@ omit =
|
||||
homeassistant/components/shodan/sensor.py
|
||||
homeassistant/components/shelly/__init__.py
|
||||
homeassistant/components/shelly/binary_sensor.py
|
||||
homeassistant/components/shelly/cover.py
|
||||
homeassistant/components/shelly/entity.py
|
||||
homeassistant/components/shelly/light.py
|
||||
homeassistant/components/shelly/sensor.py
|
||||
homeassistant/components/shelly/switch.py
|
||||
homeassistant/components/shelly/utils.py
|
||||
homeassistant/components/sht31/sensor.py
|
||||
homeassistant/components/sigfox/sensor.py
|
||||
@@ -837,7 +843,8 @@ omit =
|
||||
homeassistant/components/soma/cover.py
|
||||
homeassistant/components/soma/sensor.py
|
||||
homeassistant/components/somfy/*
|
||||
homeassistant/components/somfy_mylink/*
|
||||
homeassistant/components/somfy_mylink/__init__.py
|
||||
homeassistant/components/somfy_mylink/cover.py
|
||||
homeassistant/components/sonos/*
|
||||
homeassistant/components/sony_projector/switch.py
|
||||
homeassistant/components/spc/*
|
||||
@@ -1090,6 +1097,9 @@ omit =
|
||||
homeassistant/components/zoneminder/*
|
||||
homeassistant/components/supla/*
|
||||
homeassistant/components/zwave/util.py
|
||||
homeassistant/components/zwave_js/discovery.py
|
||||
homeassistant/components/zwave_js/light.py
|
||||
homeassistant/components/zwave_js/sensor.py
|
||||
|
||||
[report]
|
||||
# Regexes for lines to exclude from consideration
|
||||
|
||||
+20
-23
@@ -11,7 +11,7 @@ on:
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 1
|
||||
DEFAULT_PYTHON: 3.7
|
||||
DEFAULT_PYTHON: 3.8
|
||||
PRE_COMMIT_HOME: ~/.cache/pre-commit
|
||||
|
||||
jobs:
|
||||
@@ -521,13 +521,12 @@ jobs:
|
||||
needs: prepare-tests
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
python-version: [3.8]
|
||||
container: homeassistant/ci-azure:${{ matrix.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name:
|
||||
Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
@@ -585,13 +584,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7, 3.8]
|
||||
python-version: [3.8, 3.9]
|
||||
container: homeassistant/ci-azure:${{ matrix.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name:
|
||||
Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
@@ -605,10 +603,13 @@ jobs:
|
||||
${{ env.CACHE_VERSION}}-${{ runner.os }}-venv-${{ matrix.python-version }}-${{ hashFiles('requirements_test.txt') }}-${{ hashFiles('requirements_all.txt') }}
|
||||
${{ env.CACHE_VERSION}}-${{ runner.os }}-venv-${{ matrix.python-version }}-${{ hashFiles('requirements_test.txt') }}
|
||||
${{ env.CACHE_VERSION}}-${{ runner.os }}-venv-${{ matrix.python-version }}-
|
||||
- name:
|
||||
Create full Python ${{ matrix.python-version }} virtual environment
|
||||
- name: Create full Python ${{ matrix.python-version }} virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
# Temporary addition of cmake, needed to build some Python 3.9 packages
|
||||
apt-get update
|
||||
apt-get -y install cmake
|
||||
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
pip install -U "pip<20.3" setuptools wheel
|
||||
@@ -622,13 +623,12 @@ jobs:
|
||||
needs: prepare-tests
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
python-version: [3.8]
|
||||
container: homeassistant/ci-azure:${{ matrix.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name:
|
||||
Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
@@ -657,13 +657,12 @@ jobs:
|
||||
needs: prepare-tests
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
python-version: [3.8]
|
||||
container: homeassistant/ci-azure:${{ matrix.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name:
|
||||
Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
@@ -692,15 +691,14 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
group: [1, 2, 3, 4]
|
||||
python-version: [3.7, 3.8]
|
||||
python-version: [3.8, 3.9]
|
||||
name: >-
|
||||
Run tests Python ${{ matrix.python-version }} (group ${{ matrix.group }})
|
||||
container: homeassistant/ci-azure:${{ matrix.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name:
|
||||
Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
@@ -741,7 +739,7 @@ jobs:
|
||||
-p no:sugar \
|
||||
tests
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v2.2.1
|
||||
uses: actions/upload-artifact@v2.2.2
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-group${{ matrix.group }}
|
||||
path: .coverage
|
||||
@@ -755,13 +753,12 @@ jobs:
|
||||
needs: pytest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
python-version: [3.8]
|
||||
container: homeassistant/ci-azure:${{ matrix.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name:
|
||||
Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
@@ -785,4 +782,4 @@ jobs:
|
||||
coverage report --fail-under=94
|
||||
coverage xml
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v1.1.1
|
||||
uses: codecov/codecov-action@v1.2.1
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v2.0.1
|
||||
- uses: dessant/lock-threads@v2.0.3
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-lock-inactive-days: "30"
|
||||
|
||||
+44
-25
@@ -4,23 +4,27 @@ name: Stale
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# The 90 day stale policy
|
||||
# Used for: Everything (unless 30 day policy below beats it)
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@v3.0.14
|
||||
# Used for:
|
||||
# - Issues & PRs
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
- name: 90 days stale issues & PRs policy
|
||||
uses: actions/stale@v3.0.15
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
operations-per-run: 25
|
||||
operations-per-run: 150
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted"
|
||||
exempt-issue-labels: "no-stale,help-wanted"
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
@@ -43,22 +47,48 @@ jobs:
|
||||
|
||||
Thank you for your contributions.
|
||||
|
||||
# The 30 day stale policy
|
||||
# The 30 day stale policy for PRS
|
||||
# Used for:
|
||||
# - Issues that are pending more information (incomplete issues)
|
||||
# - PRs that are not marked as new-integration
|
||||
- name: 30 days stale policy
|
||||
uses: actions/stale@v3.0.14
|
||||
# - PRs
|
||||
# - No PRs marked as no-stale or new-integrations
|
||||
# - No issues (-1)
|
||||
- name: 30 days stale PRs policy
|
||||
uses: actions/stale@v3.0.15
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# PRs have a CLA signed label, we can misuse it to apply this policy
|
||||
only-labels: "cla-signed,needs-more-information"
|
||||
days-before-stale: 30
|
||||
days-before-close: 7
|
||||
operations-per-run: 5
|
||||
days-before-issue-close: -1
|
||||
operations-per-run: 50
|
||||
remove-stale-when-updated: true
|
||||
stale-pr-label: "stale"
|
||||
# Exempt new integrations, these often take more time.
|
||||
# They will automatically be handled by the 90 day version above.
|
||||
exempt-pr-labels: "no-stale,new-integration"
|
||||
stale-pr-message: >
|
||||
There hasn't been any activity on this pull request recently. This
|
||||
pull request has been automatically marked as stale because of that
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
|
||||
Thank you for your contributions.
|
||||
|
||||
# The 30 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues that are pending more information (incomplete issues)
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@v3.0.15
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
only-labels: "needs-more-information"
|
||||
days-before-stale: 14
|
||||
days-before-close: 7
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 50
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted"
|
||||
exempt-issue-labels: "no-stale,help-wanted"
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
@@ -71,14 +101,3 @@ jobs:
|
||||
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
|
||||
stale-pr-label: "stale"
|
||||
# Exempt new integrations, these often take more time.
|
||||
# They will automatically be handled by the 90 day version above.
|
||||
exempt-pr-labels: "no-stale,new-integration"
|
||||
stale-pr-message: >
|
||||
There hasn't been any activity on this pull request recently. This
|
||||
pull request has been automatically marked as stale because of that
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
|
||||
Thank you for your contributions.
|
||||
|
||||
@@ -3,7 +3,7 @@ repos:
|
||||
rev: v2.7.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py37-plus]
|
||||
args: [--py38-plus]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 20.8b1
|
||||
hooks:
|
||||
@@ -13,14 +13,15 @@ repos:
|
||||
- --quiet
|
||||
files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v1.17.1
|
||||
rev: v2.0.0
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort
|
||||
- --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort
|
||||
- --skip="./.*,*.csv,*.json"
|
||||
- --quiet-level=2
|
||||
exclude_types: [csv, json]
|
||||
exclude: ^tests/fixtures/
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.8.4
|
||||
hooks:
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build:
|
||||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.7
|
||||
version: 3.8
|
||||
setup_py_install: true
|
||||
|
||||
requirements_file: requirements_docs.txt
|
||||
|
||||
Vendored
+1
-1
@@ -5,5 +5,5 @@
|
||||
// https://github.com/microsoft/vscode-python/issues/14067
|
||||
"python.testing.pytestArgs": ["--no-cov"],
|
||||
// https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings
|
||||
"python.testing.pytestEnabled": true
|
||||
"python.testing.pytestEnabled": false
|
||||
}
|
||||
|
||||
+13
-7
@@ -26,6 +26,7 @@ homeassistant/components/adguard/* @frenck
|
||||
homeassistant/components/advantage_air/* @Bre77
|
||||
homeassistant/components/agent_dvr/* @ispysoftware
|
||||
homeassistant/components/airly/* @bieniu
|
||||
homeassistant/components/airnow/* @asymworks
|
||||
homeassistant/components/airvisual/* @bachya
|
||||
homeassistant/components/alarmdecoder/* @ajschmidt8
|
||||
homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy
|
||||
@@ -55,7 +56,6 @@ homeassistant/components/auth/* @home-assistant/core
|
||||
homeassistant/components/automation/* @home-assistant/core
|
||||
homeassistant/components/avea/* @pattyland
|
||||
homeassistant/components/awair/* @ahayworth @danielsjf
|
||||
homeassistant/components/aws/* @awarecan
|
||||
homeassistant/components/axis/* @Kane610
|
||||
homeassistant/components/azure_devops/* @timmo001
|
||||
homeassistant/components/azure_event_hub/* @eavanvalkenburg
|
||||
@@ -107,6 +107,7 @@ homeassistant/components/derivative/* @afaucogney
|
||||
homeassistant/components/device_automation/* @home-assistant/core
|
||||
homeassistant/components/devolo_home_control/* @2Fake @Shutgun
|
||||
homeassistant/components/dexcom/* @gagebenne
|
||||
homeassistant/components/dhcp/* @bdraco
|
||||
homeassistant/components/digital_ocean/* @fabaff
|
||||
homeassistant/components/directv/* @ctalkington
|
||||
homeassistant/components/discogs/* @thibmaek
|
||||
@@ -119,6 +120,7 @@ homeassistant/components/dweet/* @fabaff
|
||||
homeassistant/components/dynalite/* @ziv1234
|
||||
homeassistant/components/eafm/* @Jc2k
|
||||
homeassistant/components/ecobee/* @marthoc
|
||||
homeassistant/components/econet/* @vangorra @w1ll1am23
|
||||
homeassistant/components/ecovacs/* @OverloadUT
|
||||
homeassistant/components/edl21/* @mtdcr
|
||||
homeassistant/components/egardia/* @jeroenterheerdt
|
||||
@@ -179,7 +181,7 @@ homeassistant/components/griddy/* @bdraco
|
||||
homeassistant/components/group/* @home-assistant/core
|
||||
homeassistant/components/growatt_server/* @indykoning
|
||||
homeassistant/components/guardian/* @bachya
|
||||
homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco
|
||||
homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey
|
||||
homeassistant/components/hassio/* @home-assistant/supervisor
|
||||
homeassistant/components/hdmi_cec/* @newAM
|
||||
homeassistant/components/heatmiser/* @andylockran
|
||||
@@ -201,6 +203,7 @@ homeassistant/components/http/* @home-assistant/core
|
||||
homeassistant/components/huawei_lte/* @scop @fphammerle
|
||||
homeassistant/components/huawei_router/* @abmantis
|
||||
homeassistant/components/hue/* @balloob @frenck
|
||||
homeassistant/components/huisbaasje/* @denniss17
|
||||
homeassistant/components/humidifier/* @home-assistant/core @Shulyaka
|
||||
homeassistant/components/hunterdouglas_powerview/* @bdraco
|
||||
homeassistant/components/hvv_departures/* @vigonotion
|
||||
@@ -256,7 +259,7 @@ homeassistant/components/luci/* @mzdrale
|
||||
homeassistant/components/luftdaten/* @fabaff
|
||||
homeassistant/components/lupusec/* @majuss
|
||||
homeassistant/components/lutron/* @JonGilmore
|
||||
homeassistant/components/lutron_caseta/* @swails
|
||||
homeassistant/components/lutron_caseta/* @swails @bdraco
|
||||
homeassistant/components/mastodon/* @fabaff
|
||||
homeassistant/components/matrix/* @tinloaf
|
||||
homeassistant/components/mcp23017/* @jardiamj
|
||||
@@ -289,10 +292,10 @@ homeassistant/components/neato/* @dshokouhi @Santobert
|
||||
homeassistant/components/nederlandse_spoorwegen/* @YarmoM
|
||||
homeassistant/components/nello/* @pschmitt
|
||||
homeassistant/components/ness_alarm/* @nickw444
|
||||
homeassistant/components/nest/* @awarecan @allenporter
|
||||
homeassistant/components/nest/* @allenporter
|
||||
homeassistant/components/netatmo/* @cgtobi
|
||||
homeassistant/components/netdata/* @fabaff
|
||||
homeassistant/components/nexia/* @ryannazaretian @bdraco
|
||||
homeassistant/components/nexia/* @bdraco
|
||||
homeassistant/components/nextbus/* @vividboarder
|
||||
homeassistant/components/nextcloud/* @meichthys
|
||||
homeassistant/components/nightscout/* @marciogranzotto
|
||||
@@ -318,6 +321,7 @@ homeassistant/components/ohmconnect/* @robbiet480
|
||||
homeassistant/components/ombi/* @larssont
|
||||
homeassistant/components/omnilogic/* @oliver84 @djtimca @gentoosu
|
||||
homeassistant/components/onboarding/* @home-assistant/core
|
||||
homeassistant/components/ondilo_ico/* @JeromeHXP
|
||||
homeassistant/components/onewire/* @garbled1 @epenet
|
||||
homeassistant/components/onvif/* @hunterjm
|
||||
homeassistant/components/openerz/* @misialq
|
||||
@@ -351,7 +355,6 @@ homeassistant/components/progettihwsw/* @ardaseremet
|
||||
homeassistant/components/prometheus/* @knyar
|
||||
homeassistant/components/proxmoxve/* @k4ds3 @jhollowe
|
||||
homeassistant/components/ps4/* @ktnrg45
|
||||
homeassistant/components/ptvsd/* @swamp-ig
|
||||
homeassistant/components/push/* @dgomes
|
||||
homeassistant/components/pvoutput/* @fabaff
|
||||
homeassistant/components/pvpc_hourly_pricing/* @azogue
|
||||
@@ -362,6 +365,7 @@ homeassistant/components/quantum_gateway/* @cisasteelersfan
|
||||
homeassistant/components/qvr_pro/* @oblogic7
|
||||
homeassistant/components/qwikswitch/* @kellerza
|
||||
homeassistant/components/rachio/* @bdraco
|
||||
homeassistant/components/radiotherm/* @vinnyfuria
|
||||
homeassistant/components/rainbird/* @konikvranik
|
||||
homeassistant/components/raincloud/* @vanstinator
|
||||
homeassistant/components/rainforest_eagle/* @gtdiehl @jcalbert
|
||||
@@ -396,7 +400,7 @@ homeassistant/components/seven_segments/* @fabaff
|
||||
homeassistant/components/seventeentrack/* @bachya
|
||||
homeassistant/components/sharkiq/* @ajmarks
|
||||
homeassistant/components/shell_command/* @home-assistant/core
|
||||
homeassistant/components/shelly/* @balloob @bieniu @thecode
|
||||
homeassistant/components/shelly/* @balloob @bieniu @thecode @chemelli74
|
||||
homeassistant/components/shiftr/* @fabaff
|
||||
homeassistant/components/shodan/* @fabaff
|
||||
homeassistant/components/sighthound/* @robmarkcole
|
||||
@@ -420,6 +424,7 @@ homeassistant/components/solarlog/* @Ernst79
|
||||
homeassistant/components/solax/* @squishykid
|
||||
homeassistant/components/soma/* @ratsept
|
||||
homeassistant/components/somfy/* @tetienne
|
||||
homeassistant/components/somfy_mylink/* @bdraco
|
||||
homeassistant/components/sonarr/* @ctalkington
|
||||
homeassistant/components/songpal/* @rytilahti @shenxn
|
||||
homeassistant/components/sonos/* @cgtobi
|
||||
@@ -535,6 +540,7 @@ homeassistant/components/zodiac/* @JulienTant
|
||||
homeassistant/components/zone/* @home-assistant/core
|
||||
homeassistant/components/zoneminder/* @rohankapoorcom
|
||||
homeassistant/components/zwave/* @home-assistant/z-wave
|
||||
homeassistant/components/zwave_js/* @home-assistant/z-wave
|
||||
|
||||
# Individual files
|
||||
homeassistant/components/demo/weather @fabaff
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
ARG BUILD_FROM
|
||||
FROM ${BUILD_FROM}
|
||||
|
||||
# Synchronize with homeassistant/core.py:async_stop
|
||||
ENV \
|
||||
S6_SERVICES_GRACETIME=60000
|
||||
S6_SERVICES_GRACETIME=220000
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ pr:
|
||||
|
||||
resources:
|
||||
containers:
|
||||
- container: 37
|
||||
image: homeassistant/ci-azure:3.7
|
||||
- container: 38
|
||||
image: homeassistant/ci-azure:3.8
|
||||
repositories:
|
||||
@@ -25,7 +23,7 @@ resources:
|
||||
endpoint: "home-assistant"
|
||||
variables:
|
||||
- name: PythonMain
|
||||
value: "37"
|
||||
value: "38"
|
||||
- name: versionHadolint
|
||||
value: "v1.17.6"
|
||||
|
||||
@@ -150,8 +148,6 @@ stages:
|
||||
strategy:
|
||||
maxParallel: 3
|
||||
matrix:
|
||||
Python37:
|
||||
python.container: "37"
|
||||
Python38:
|
||||
python.container: "38"
|
||||
container: $[ variables['python.container'] ]
|
||||
|
||||
@@ -60,9 +60,9 @@ stages:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
- task: UsePythonVersion@0
|
||||
displayName: 'Use Python 3.7'
|
||||
displayName: 'Use Python 3.8'
|
||||
inputs:
|
||||
versionSpec: '3.7'
|
||||
versionSpec: '3.8'
|
||||
- script: pip install twine wheel
|
||||
displayName: 'Install tools'
|
||||
- script: python setup.py sdist bdist_wheel
|
||||
|
||||
@@ -30,9 +30,9 @@ jobs:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
- task: UsePythonVersion@0
|
||||
displayName: 'Use Python 3.7'
|
||||
displayName: 'Use Python 3.8'
|
||||
inputs:
|
||||
versionSpec: '3.7'
|
||||
versionSpec: '3.8'
|
||||
- script: |
|
||||
export LOKALISE_TOKEN="$(lokaliseToken)"
|
||||
export AZURE_BRANCH="$(Build.SourceBranchName)"
|
||||
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"image": "homeassistant/{arch}-homeassistant",
|
||||
"build_from": {
|
||||
"aarch64": "homeassistant/aarch64-homeassistant-base:2020.11.2",
|
||||
"armhf": "homeassistant/armhf-homeassistant-base:2020.11.2",
|
||||
"armv7": "homeassistant/armv7-homeassistant-base:2020.11.2",
|
||||
"amd64": "homeassistant/amd64-homeassistant-base:2020.11.2",
|
||||
"i386": "homeassistant/i386-homeassistant-base:2020.11.2"
|
||||
"aarch64": "homeassistant/aarch64-homeassistant-base:2021.01.1",
|
||||
"armhf": "homeassistant/armhf-homeassistant-base:2021.01.1",
|
||||
"armv7": "homeassistant/armv7-homeassistant-base:2021.01.1",
|
||||
"amd64": "homeassistant/amd64-homeassistant-base:2021.01.1",
|
||||
"i386": "homeassistant/i386-homeassistant-base:2021.01.1"
|
||||
},
|
||||
"labels": {
|
||||
"io.hass.type": "core"
|
||||
|
||||
@@ -48,7 +48,7 @@ COOLDOWN_TIME = 60
|
||||
|
||||
MAX_LOAD_CONCURRENTLY = 6
|
||||
|
||||
DEBUGGER_INTEGRATIONS = {"debugpy", "ptvsd"}
|
||||
DEBUGGER_INTEGRATIONS = {"debugpy"}
|
||||
CORE_INTEGRATIONS = ("homeassistant", "persistent_notification")
|
||||
LOGGING_INTEGRATIONS = {
|
||||
# Set log levels
|
||||
@@ -307,12 +307,10 @@ def async_enable_logging(
|
||||
sys.excepthook = lambda *args: logging.getLogger(None).exception(
|
||||
"Uncaught exception", exc_info=args # type: ignore
|
||||
)
|
||||
|
||||
if sys.version_info[:2] >= (3, 8):
|
||||
threading.excepthook = lambda args: logging.getLogger(None).exception(
|
||||
"Uncaught thread exception",
|
||||
exc_info=(args.exc_type, args.exc_value, args.exc_traceback),
|
||||
)
|
||||
threading.excepthook = lambda args: logging.getLogger(None).exception(
|
||||
"Uncaught thread exception",
|
||||
exc_info=(args.exc_type, args.exc_value, args.exc_traceback), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# Log errors to a file if we have write access to file or config dir
|
||||
if log_file is None:
|
||||
@@ -383,7 +381,7 @@ def _get_domains(hass: core.HomeAssistant, config: Dict[str, Any]) -> Set[str]:
|
||||
|
||||
|
||||
async def _async_log_pending_setups(
|
||||
domains: Set[str], setup_started: Dict[str, datetime]
|
||||
hass: core.HomeAssistant, domains: Set[str], setup_started: Dict[str, datetime]
|
||||
) -> None:
|
||||
"""Periodic log of setups that are pending for longer than LOG_SLOW_STARTUP_INTERVAL."""
|
||||
while True:
|
||||
@@ -395,6 +393,7 @@ async def _async_log_pending_setups(
|
||||
"Waiting on integrations to complete setup: %s",
|
||||
", ".join(remaining),
|
||||
)
|
||||
_LOGGER.debug("Running timeout Zones: %s", hass.timeout.zones)
|
||||
|
||||
|
||||
async def async_setup_multi_components(
|
||||
@@ -408,7 +407,9 @@ async def async_setup_multi_components(
|
||||
domain: hass.async_create_task(async_setup_component(hass, domain, config))
|
||||
for domain in domains
|
||||
}
|
||||
log_task = asyncio.create_task(_async_log_pending_setups(domains, setup_started))
|
||||
log_task = asyncio.create_task(
|
||||
_async_log_pending_setups(hass, domains, setup_started)
|
||||
)
|
||||
await asyncio.wait(futures.values())
|
||||
log_task.cancel()
|
||||
errors = [domain for domain in domains if futures[domain].exception()]
|
||||
|
||||
@@ -100,13 +100,13 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
self.accuweather = AccuWeather(api_key, session, location_key=self.location_key)
|
||||
|
||||
# Enabling the forecast download increases the number of requests per data
|
||||
# update, we use 32 minutes for current condition only and 64 minutes for
|
||||
# update, we use 40 minutes for current condition only and 80 minutes for
|
||||
# current condition and forecast as update interval to not exceed allowed number
|
||||
# of requests. We have 50 requests allowed per day, so we use 45 and leave 5 as
|
||||
# of requests. We have 50 requests allowed per day, so we use 36 and leave 14 as
|
||||
# a reserve for restarting HA.
|
||||
update_interval = (
|
||||
timedelta(minutes=64) if self.forecast else timedelta(minutes=32)
|
||||
)
|
||||
update_interval = timedelta(minutes=40)
|
||||
if self.forecast:
|
||||
update_interval *= 2
|
||||
_LOGGER.debug("Data will be update every %s", update_interval)
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
|
||||
|
||||
@@ -52,12 +52,12 @@ CONDITION_CLASSES = {
|
||||
ATTR_CONDITION_HAIL: [25],
|
||||
ATTR_CONDITION_LIGHTNING: [15],
|
||||
ATTR_CONDITION_LIGHTNING_RAINY: [16, 17, 41, 42],
|
||||
ATTR_CONDITION_PARTLYCLOUDY: [4, 6, 35, 36],
|
||||
ATTR_CONDITION_PARTLYCLOUDY: [3, 4, 6, 35, 36],
|
||||
ATTR_CONDITION_POURING: [18],
|
||||
ATTR_CONDITION_RAINY: [12, 13, 14, 26, 39, 40],
|
||||
ATTR_CONDITION_SNOWY: [19, 20, 21, 22, 23, 43, 44],
|
||||
ATTR_CONDITION_SNOWY_RAINY: [29],
|
||||
ATTR_CONDITION_SUNNY: [1, 2, 3, 5],
|
||||
ATTR_CONDITION_SUNNY: [1, 2, 5],
|
||||
ATTR_CONDITION_WINDY: [32],
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "AccuWeather Options",
|
||||
"description": "Due to the limitations of the free version of the AccuWeather API key, when you enable weather forecast, data updates will be performed every 64 minutes instead of every 32 minutes.",
|
||||
"description": "Due to the limitations of the free version of the AccuWeather API key, when you enable weather forecast, data updates will be performed every 80 minutes instead of every 40 minutes.",
|
||||
"data": {
|
||||
"forecast": "Weather forecast"
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@ class AcmedaBase(entity.Entity):
|
||||
ent_registry.async_remove(self.entity_id)
|
||||
|
||||
dev_registry = await get_dev_reg(self.hass)
|
||||
device = dev_registry.async_get_device(
|
||||
identifiers={(DOMAIN, self.unique_id)}, connections=set()
|
||||
)
|
||||
device = dev_registry.async_get_device(identifiers={(DOMAIN, self.unique_id)})
|
||||
if device is not None:
|
||||
dev_registry.async_update_device(
|
||||
device.id, remove_config_entry_id=self.registry_entry.config_entry_id
|
||||
|
||||
@@ -32,9 +32,7 @@ async def update_devices(hass, config_entry, api):
|
||||
|
||||
for api_item in api.values():
|
||||
# Update Device name
|
||||
device = dev_registry.async_get_device(
|
||||
identifiers={(DOMAIN, api_item.id)}, connections=set()
|
||||
)
|
||||
device = dev_registry.async_get_device(identifiers={(DOMAIN, api_item.id)})
|
||||
if device is not None:
|
||||
dev_registry.async_update_device(
|
||||
device.id,
|
||||
|
||||
@@ -181,8 +181,8 @@ class AfterShipSensor(Entity):
|
||||
track["tracking_number"] if track["title"] is None else track["title"]
|
||||
)
|
||||
last_checkpoint = (
|
||||
"Shipment pending"
|
||||
if track["tag"] == "Pending"
|
||||
f"Shipment {track['tag'].lower()}"
|
||||
if not track["checkpoints"]
|
||||
else track["checkpoints"][-1]
|
||||
)
|
||||
status_counts[status] = status_counts.get(status, 0) + 1
|
||||
|
||||
@@ -20,6 +20,7 @@ from .const import (
|
||||
ATTR_API_CAQI,
|
||||
ATTR_API_CAQI_DESCRIPTION,
|
||||
ATTR_API_CAQI_LEVEL,
|
||||
CONF_USE_NEAREST,
|
||||
DOMAIN,
|
||||
MAX_REQUESTS_PER_DAY,
|
||||
NO_AIRLY_SENSORS,
|
||||
@@ -53,6 +54,7 @@ async def async_setup_entry(hass, config_entry):
|
||||
api_key = config_entry.data[CONF_API_KEY]
|
||||
latitude = config_entry.data[CONF_LATITUDE]
|
||||
longitude = config_entry.data[CONF_LONGITUDE]
|
||||
use_nearest = config_entry.data.get(CONF_USE_NEAREST, False)
|
||||
|
||||
# For backwards compat, set unique ID
|
||||
if config_entry.unique_id is None:
|
||||
@@ -67,7 +69,7 @@ async def async_setup_entry(hass, config_entry):
|
||||
)
|
||||
|
||||
coordinator = AirlyDataUpdateCoordinator(
|
||||
hass, websession, api_key, latitude, longitude, update_interval
|
||||
hass, websession, api_key, latitude, longitude, update_interval, use_nearest
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
@@ -107,21 +109,36 @@ async def async_unload_entry(hass, config_entry):
|
||||
class AirlyDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Define an object to hold Airly data."""
|
||||
|
||||
def __init__(self, hass, session, api_key, latitude, longitude, update_interval):
|
||||
def __init__(
|
||||
self,
|
||||
hass,
|
||||
session,
|
||||
api_key,
|
||||
latitude,
|
||||
longitude,
|
||||
update_interval,
|
||||
use_nearest,
|
||||
):
|
||||
"""Initialize."""
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.airly = Airly(api_key, session)
|
||||
self.use_nearest = use_nearest
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Update data via library."""
|
||||
data = {}
|
||||
with async_timeout.timeout(20):
|
||||
if self.use_nearest:
|
||||
measurements = self.airly.create_measurements_session_nearest(
|
||||
self.latitude, self.longitude, max_distance_km=5
|
||||
)
|
||||
else:
|
||||
measurements = self.airly.create_measurements_session_point(
|
||||
self.latitude, self.longitude
|
||||
)
|
||||
with async_timeout.timeout(20):
|
||||
try:
|
||||
await measurements.update()
|
||||
except (AirlyError, ClientConnectorError) as error:
|
||||
|
||||
@@ -87,13 +87,13 @@ class AirlyAirQuality(CoordinatorEntity, AirQualityEntity):
|
||||
@round_state
|
||||
def particulate_matter_2_5(self):
|
||||
"""Return the particulate matter 2.5 level."""
|
||||
return self.coordinator.data[ATTR_API_PM25]
|
||||
return self.coordinator.data.get(ATTR_API_PM25)
|
||||
|
||||
@property
|
||||
@round_state
|
||||
def particulate_matter_10(self):
|
||||
"""Return the particulate matter 10 level."""
|
||||
return self.coordinator.data[ATTR_API_PM10]
|
||||
return self.coordinator.data.get(ATTR_API_PM10)
|
||||
|
||||
@property
|
||||
def attribution(self):
|
||||
@@ -120,12 +120,19 @@ class AirlyAirQuality(CoordinatorEntity, AirQualityEntity):
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
attrs = {
|
||||
LABEL_AQI_DESCRIPTION: self.coordinator.data[ATTR_API_CAQI_DESCRIPTION],
|
||||
LABEL_ADVICE: self.coordinator.data[ATTR_API_ADVICE],
|
||||
LABEL_AQI_LEVEL: self.coordinator.data[ATTR_API_CAQI_LEVEL],
|
||||
LABEL_PM_2_5_LIMIT: self.coordinator.data[ATTR_API_PM25_LIMIT],
|
||||
LABEL_PM_2_5_PERCENT: round(self.coordinator.data[ATTR_API_PM25_PERCENT]),
|
||||
LABEL_PM_10_LIMIT: self.coordinator.data[ATTR_API_PM10_LIMIT],
|
||||
LABEL_PM_10_PERCENT: round(self.coordinator.data[ATTR_API_PM10_PERCENT]),
|
||||
}
|
||||
if ATTR_API_PM25 in self.coordinator.data:
|
||||
attrs[LABEL_PM_2_5_LIMIT] = self.coordinator.data[ATTR_API_PM25_LIMIT]
|
||||
attrs[LABEL_PM_2_5_PERCENT] = round(
|
||||
self.coordinator.data[ATTR_API_PM25_PERCENT]
|
||||
)
|
||||
if ATTR_API_PM10 in self.coordinator.data:
|
||||
attrs[LABEL_PM_10_LIMIT] = self.coordinator.data[ATTR_API_PM10_LIMIT]
|
||||
attrs[LABEL_PM_10_PERCENT] = round(
|
||||
self.coordinator.data[ATTR_API_PM10_PERCENT]
|
||||
)
|
||||
return attrs
|
||||
|
||||
@@ -10,12 +10,17 @@ from homeassistant.const import (
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_NAME,
|
||||
HTTP_NOT_FOUND,
|
||||
HTTP_UNAUTHORIZED,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import DOMAIN, NO_AIRLY_SENSORS # pylint:disable=unused-import
|
||||
from .const import ( # pylint:disable=unused-import
|
||||
CONF_USE_NEAREST,
|
||||
DOMAIN,
|
||||
NO_AIRLY_SENSORS,
|
||||
)
|
||||
|
||||
|
||||
class AirlyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
@@ -27,6 +32,7 @@ class AirlyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initialized by the user."""
|
||||
errors = {}
|
||||
use_nearest = False
|
||||
|
||||
websession = async_get_clientsession(self.hass)
|
||||
|
||||
@@ -36,23 +42,32 @@ class AirlyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
try:
|
||||
location_valid = await test_location(
|
||||
location_point_valid = await test_location(
|
||||
websession,
|
||||
user_input["api_key"],
|
||||
user_input["latitude"],
|
||||
user_input["longitude"],
|
||||
)
|
||||
if not location_point_valid:
|
||||
await test_location(
|
||||
websession,
|
||||
user_input["api_key"],
|
||||
user_input["latitude"],
|
||||
user_input["longitude"],
|
||||
use_nearest=True,
|
||||
)
|
||||
except AirlyError as err:
|
||||
if err.status_code == HTTP_UNAUTHORIZED:
|
||||
errors["base"] = "invalid_api_key"
|
||||
else:
|
||||
if not location_valid:
|
||||
if err.status_code == HTTP_NOT_FOUND:
|
||||
errors["base"] = "wrong_location"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME], data=user_input
|
||||
)
|
||||
else:
|
||||
if not location_point_valid:
|
||||
use_nearest = True
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME],
|
||||
data={**user_input, CONF_USE_NEAREST: use_nearest},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
@@ -74,13 +89,17 @@ class AirlyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
|
||||
async def test_location(client, api_key, latitude, longitude):
|
||||
async def test_location(client, api_key, latitude, longitude, use_nearest=False):
|
||||
"""Return true if location is valid."""
|
||||
airly = Airly(api_key, client)
|
||||
measurements = airly.create_measurements_session_point(
|
||||
latitude=latitude, longitude=longitude
|
||||
)
|
||||
|
||||
if use_nearest:
|
||||
measurements = airly.create_measurements_session_nearest(
|
||||
latitude=latitude, longitude=longitude, max_distance_km=5
|
||||
)
|
||||
else:
|
||||
measurements = airly.create_measurements_session_point(
|
||||
latitude=latitude, longitude=longitude
|
||||
)
|
||||
with async_timeout.timeout(10):
|
||||
await measurements.update()
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ ATTR_API_PM25_LIMIT = "PM25_LIMIT"
|
||||
ATTR_API_PM25_PERCENT = "PM25_PERCENT"
|
||||
ATTR_API_PRESSURE = "PRESSURE"
|
||||
ATTR_API_TEMPERATURE = "TEMPERATURE"
|
||||
CONF_USE_NEAREST = "use_nearest"
|
||||
DEFAULT_NAME = "Airly"
|
||||
DOMAIN = "airly"
|
||||
MANUFACTURER = "Airly sp. z o.o."
|
||||
|
||||
@@ -67,7 +67,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
|
||||
sensors = []
|
||||
for sensor in SENSOR_TYPES:
|
||||
sensors.append(AirlySensor(coordinator, name, sensor))
|
||||
# When we use the nearest method, we are not sure which sensors are available
|
||||
if coordinator.data.get(sensor):
|
||||
sensors.append(AirlySensor(coordinator, name, sensor))
|
||||
|
||||
async_add_entities(sensors, False)
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""The AirNow integration."""
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from aiohttp.client_exceptions import ClientConnectorError
|
||||
from pyairnow import WebServiceAPI
|
||||
from pyairnow.conv import aqi_to_concentration
|
||||
from pyairnow.errors import AirNowError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
ATTR_API_AQI,
|
||||
ATTR_API_AQI_DESCRIPTION,
|
||||
ATTR_API_AQI_LEVEL,
|
||||
ATTR_API_AQI_PARAM,
|
||||
ATTR_API_CAT_DESCRIPTION,
|
||||
ATTR_API_CAT_LEVEL,
|
||||
ATTR_API_CATEGORY,
|
||||
ATTR_API_PM25,
|
||||
ATTR_API_POLLUTANT,
|
||||
ATTR_API_REPORT_DATE,
|
||||
ATTR_API_REPORT_HOUR,
|
||||
ATTR_API_STATE,
|
||||
ATTR_API_STATION,
|
||||
ATTR_API_STATION_LATITUDE,
|
||||
ATTR_API_STATION_LONGITUDE,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS = ["sensor"]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict):
|
||||
"""Set up the AirNow component."""
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Set up AirNow from a config entry."""
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
latitude = entry.data[CONF_LATITUDE]
|
||||
longitude = entry.data[CONF_LONGITUDE]
|
||||
distance = entry.data[CONF_RADIUS]
|
||||
|
||||
# Reports are published hourly but update twice per hour
|
||||
update_interval = datetime.timedelta(minutes=30)
|
||||
|
||||
# Setup the Coordinator
|
||||
session = async_get_clientsession(hass)
|
||||
coordinator = AirNowDataUpdateCoordinator(
|
||||
hass, session, api_key, latitude, longitude, distance, update_interval
|
||||
)
|
||||
|
||||
# Sync with Coordinator
|
||||
await coordinator.async_refresh()
|
||||
if not coordinator.last_update_success:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
# Store Entity and Initialize Platforms
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
for component in PLATFORMS:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(entry, component)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Unload a config entry."""
|
||||
unload_ok = all(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_unload(entry, component)
|
||||
for component in PLATFORMS
|
||||
]
|
||||
)
|
||||
)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
class AirNowDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Define an object to hold Airly data."""
|
||||
|
||||
def __init__(
|
||||
self, hass, session, api_key, latitude, longitude, distance, update_interval
|
||||
):
|
||||
"""Initialize."""
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.distance = distance
|
||||
|
||||
self.airnow = WebServiceAPI(api_key, session=session)
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Update data via library."""
|
||||
data = {}
|
||||
try:
|
||||
obs = await self.airnow.observations.latLong(
|
||||
self.latitude,
|
||||
self.longitude,
|
||||
distance=self.distance,
|
||||
)
|
||||
|
||||
except (AirNowError, ClientConnectorError) as error:
|
||||
raise UpdateFailed(error) from error
|
||||
|
||||
if not obs:
|
||||
raise UpdateFailed("No data was returned from AirNow")
|
||||
|
||||
max_aqi = 0
|
||||
max_aqi_level = 0
|
||||
max_aqi_desc = ""
|
||||
max_aqi_poll = ""
|
||||
for obv in obs:
|
||||
# Convert AQIs to Concentration
|
||||
pollutant = obv[ATTR_API_AQI_PARAM]
|
||||
concentration = aqi_to_concentration(obv[ATTR_API_AQI], pollutant)
|
||||
data[obv[ATTR_API_AQI_PARAM]] = concentration
|
||||
|
||||
# Overall AQI is the max of all pollutant AQIs
|
||||
if obv[ATTR_API_AQI] > max_aqi:
|
||||
max_aqi = obv[ATTR_API_AQI]
|
||||
max_aqi_level = obv[ATTR_API_CATEGORY][ATTR_API_CAT_LEVEL]
|
||||
max_aqi_desc = obv[ATTR_API_CATEGORY][ATTR_API_CAT_DESCRIPTION]
|
||||
max_aqi_poll = pollutant
|
||||
|
||||
# Copy other data from PM2.5 Value
|
||||
if obv[ATTR_API_AQI_PARAM] == ATTR_API_PM25:
|
||||
# Copy Report Details
|
||||
data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE]
|
||||
data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR]
|
||||
|
||||
# Copy Station Details
|
||||
data[ATTR_API_STATE] = obv[ATTR_API_STATE]
|
||||
data[ATTR_API_STATION] = obv[ATTR_API_STATION]
|
||||
data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE]
|
||||
data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE]
|
||||
|
||||
# Store Overall AQI
|
||||
data[ATTR_API_AQI] = max_aqi
|
||||
data[ATTR_API_AQI_LEVEL] = max_aqi_level
|
||||
data[ATTR_API_AQI_DESCRIPTION] = max_aqi_desc
|
||||
data[ATTR_API_POLLUTANT] = max_aqi_poll
|
||||
|
||||
return data
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Config flow for AirNow integration."""
|
||||
import logging
|
||||
|
||||
from pyairnow import WebServiceAPI
|
||||
from pyairnow.errors import AirNowError, InvalidKeyError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, core, exceptions
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import DOMAIN # pylint:disable=unused-import
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def validate_input(hass: core.HomeAssistant, data):
|
||||
"""
|
||||
Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
session = async_get_clientsession(hass)
|
||||
client = WebServiceAPI(data[CONF_API_KEY], session=session)
|
||||
|
||||
lat = data[CONF_LATITUDE]
|
||||
lng = data[CONF_LONGITUDE]
|
||||
distance = data[CONF_RADIUS]
|
||||
|
||||
# Check that the provided latitude/longitude provide a response
|
||||
try:
|
||||
test_data = await client.observations.latLong(lat, lng, distance=distance)
|
||||
|
||||
except InvalidKeyError as exc:
|
||||
raise InvalidAuth from exc
|
||||
except AirNowError as exc:
|
||||
raise CannotConnect from exc
|
||||
|
||||
if not test_data:
|
||||
raise InvalidLocation
|
||||
|
||||
# Validation Succeeded
|
||||
return True
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for AirNow."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
# Set a unique id based on latitude/longitude
|
||||
await self.async_set_unique_id(
|
||||
f"{user_input[CONF_LATITUDE]}-{user_input[CONF_LONGITUDE]}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
try:
|
||||
# Validate inputs
|
||||
await validate_input(self.hass, user_input)
|
||||
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except InvalidLocation:
|
||||
errors["base"] = "invalid_location"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
# Create Entry
|
||||
return self.async_create_entry(
|
||||
title=f"AirNow Sensor at {user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Optional(
|
||||
CONF_LATITUDE, default=self.hass.config.latitude
|
||||
): cv.latitude,
|
||||
vol.Optional(
|
||||
CONF_LONGITUDE, default=self.hass.config.longitude
|
||||
): cv.longitude,
|
||||
vol.Optional(CONF_RADIUS, default=150): int,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
|
||||
class InvalidLocation(exceptions.HomeAssistantError):
|
||||
"""Error to indicate the location is invalid."""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Constants for the AirNow integration."""
|
||||
ATTR_API_AQI = "AQI"
|
||||
ATTR_API_AQI_LEVEL = "Category.Number"
|
||||
ATTR_API_AQI_DESCRIPTION = "Category.Name"
|
||||
ATTR_API_AQI_PARAM = "ParameterName"
|
||||
ATTR_API_CATEGORY = "Category"
|
||||
ATTR_API_CAT_LEVEL = "Number"
|
||||
ATTR_API_CAT_DESCRIPTION = "Name"
|
||||
ATTR_API_O3 = "O3"
|
||||
ATTR_API_PM25 = "PM2.5"
|
||||
ATTR_API_POLLUTANT = "Pollutant"
|
||||
ATTR_API_REPORT_DATE = "HourObserved"
|
||||
ATTR_API_REPORT_HOUR = "DateObserved"
|
||||
ATTR_API_STATE = "StateCode"
|
||||
ATTR_API_STATION = "ReportingArea"
|
||||
ATTR_API_STATION_LATITUDE = "Latitude"
|
||||
ATTR_API_STATION_LONGITUDE = "Longitude"
|
||||
DEFAULT_NAME = "AirNow"
|
||||
DOMAIN = "airnow"
|
||||
SENSOR_AQI_ATTR_DESCR = "description"
|
||||
SENSOR_AQI_ATTR_LEVEL = "level"
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "airnow",
|
||||
"name": "AirNow",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airnow",
|
||||
"requirements": [
|
||||
"pyairnow==1.1.0"
|
||||
],
|
||||
"codeowners": [
|
||||
"@asymworks"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Support for the AirNow sensor service."""
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION,
|
||||
ATTR_DEVICE_CLASS,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
ATTR_API_AQI,
|
||||
ATTR_API_AQI_DESCRIPTION,
|
||||
ATTR_API_AQI_LEVEL,
|
||||
ATTR_API_O3,
|
||||
ATTR_API_PM25,
|
||||
DOMAIN,
|
||||
SENSOR_AQI_ATTR_DESCR,
|
||||
SENSOR_AQI_ATTR_LEVEL,
|
||||
)
|
||||
|
||||
ATTRIBUTION = "Data provided by AirNow"
|
||||
|
||||
ATTR_ICON = "icon"
|
||||
ATTR_LABEL = "label"
|
||||
ATTR_UNIT = "unit"
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
SENSOR_TYPES = {
|
||||
ATTR_API_AQI: {
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:blur",
|
||||
ATTR_LABEL: ATTR_API_AQI,
|
||||
ATTR_UNIT: "aqi",
|
||||
},
|
||||
ATTR_API_PM25: {
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:blur",
|
||||
ATTR_LABEL: ATTR_API_PM25,
|
||||
ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
ATTR_API_O3: {
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:blur",
|
||||
ATTR_LABEL: ATTR_API_O3,
|
||||
ATTR_UNIT: CONCENTRATION_PARTS_PER_MILLION,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up AirNow sensor entities based on a config entry."""
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
sensors = []
|
||||
for sensor in SENSOR_TYPES:
|
||||
sensors.append(AirNowSensor(coordinator, sensor))
|
||||
|
||||
async_add_entities(sensors, False)
|
||||
|
||||
|
||||
class AirNowSensor(CoordinatorEntity):
|
||||
"""Define an AirNow sensor."""
|
||||
|
||||
def __init__(self, coordinator, kind):
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
self.kind = kind
|
||||
self._device_class = None
|
||||
self._state = None
|
||||
self._icon = None
|
||||
self._unit_of_measurement = None
|
||||
self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION}
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name."""
|
||||
return f"AirNow {SENSOR_TYPES[self.kind][ATTR_LABEL]}"
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state."""
|
||||
self._state = self.coordinator.data[self.kind]
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
if self.kind == ATTR_API_AQI:
|
||||
self._attrs[SENSOR_AQI_ATTR_DESCR] = self.coordinator.data[
|
||||
ATTR_API_AQI_DESCRIPTION
|
||||
]
|
||||
self._attrs[SENSOR_AQI_ATTR_LEVEL] = self.coordinator.data[
|
||||
ATTR_API_AQI_LEVEL
|
||||
]
|
||||
|
||||
return self._attrs
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon."""
|
||||
self._icon = SENSOR_TYPES[self.kind][ATTR_ICON]
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device_class."""
|
||||
return SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS]
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique_id for this entity."""
|
||||
return f"{self.coordinator.latitude}-{self.coordinator.longitude}-{self.kind.lower()}"
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the unit the value is expressed in."""
|
||||
return SENSOR_TYPES[self.kind][ATTR_UNIT]
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"title": "AirNow",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "AirNow",
|
||||
"description": "Set up AirNow air quality integration. To generate API key go to https://docs.airnowapi.org/account/request/",
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"latitude": "[%key:common::config_flow::data::latitude%]",
|
||||
"longitude": "[%key:common::config_flow::data::longitude%]",
|
||||
"radius": "Station Radius (miles; optional)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_location": "No results found for that location",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_location": "No results found for that location",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "API Key",
|
||||
"latitude": "Latitude",
|
||||
"longitude": "Longitude",
|
||||
"name": "Name of the Entity",
|
||||
"radius": "Station Radius (miles; optional)"
|
||||
},
|
||||
"description": "Set up AirNow air quality integration. To generate API key go to https://docs.airnowapi.org/account/request/",
|
||||
"title": "AirNow"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "AirNow"
|
||||
}
|
||||
@@ -58,25 +58,6 @@ NODE_PRO_SENSORS = [
|
||||
(SENSOR_KIND_TEMPERATURE, "Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS),
|
||||
]
|
||||
|
||||
POLLUTANT_LEVEL_MAPPING = [
|
||||
{"label": "Good", "icon": "mdi:emoticon-excited", "minimum": 0, "maximum": 50},
|
||||
{"label": "Moderate", "icon": "mdi:emoticon-happy", "minimum": 51, "maximum": 100},
|
||||
{
|
||||
"label": "Unhealthy for sensitive groups",
|
||||
"icon": "mdi:emoticon-neutral",
|
||||
"minimum": 101,
|
||||
"maximum": 150,
|
||||
},
|
||||
{"label": "Unhealthy", "icon": "mdi:emoticon-sad", "minimum": 151, "maximum": 200},
|
||||
{
|
||||
"label": "Very Unhealthy",
|
||||
"icon": "mdi:emoticon-dead",
|
||||
"minimum": 201,
|
||||
"maximum": 300,
|
||||
},
|
||||
{"label": "Hazardous", "icon": "mdi:biohazard", "minimum": 301, "maximum": 10000},
|
||||
]
|
||||
|
||||
POLLUTANT_MAPPING = {
|
||||
"co": {"label": "Carbon Monoxide", "unit": CONCENTRATION_PARTS_PER_MILLION},
|
||||
"n2": {"label": "Nitrogen Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION},
|
||||
@@ -87,6 +68,22 @@ POLLUTANT_MAPPING = {
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_pollutant_level_info(value):
|
||||
"""Return a verbal pollutant level (and associated icon) for a numeric value."""
|
||||
if 0 <= value <= 50:
|
||||
return ("Good", "mdi:emoticon-excited")
|
||||
if 51 <= value <= 100:
|
||||
return ("Moderate", "mdi:emoticon-happy")
|
||||
if 101 <= value <= 150:
|
||||
return ("Unhealthy for sensitive groups", "mdi:emoticon-neutral")
|
||||
if 151 <= value <= 200:
|
||||
return ("Unhealthy", "mdi:emoticon-sad")
|
||||
if 201 <= value <= 300:
|
||||
return ("Very Unhealthy", "mdi:emoticon-dead")
|
||||
return ("Hazardous", "mdi:biohazard")
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up AirVisual sensors based on a config entry."""
|
||||
coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id]
|
||||
@@ -171,13 +168,7 @@ class AirVisualGeographySensor(AirVisualEntity):
|
||||
|
||||
if self._kind == SENSOR_KIND_LEVEL:
|
||||
aqi = data[f"aqi{self._locale}"]
|
||||
[level] = [
|
||||
i
|
||||
for i in POLLUTANT_LEVEL_MAPPING
|
||||
if i["minimum"] <= aqi <= i["maximum"]
|
||||
]
|
||||
self._state = level["label"]
|
||||
self._icon = level["icon"]
|
||||
self._state, self._icon = async_get_pollutant_level_info(aqi)
|
||||
elif self._kind == SENSOR_KIND_AQI:
|
||||
self._state = data[f"aqi{self._locale}"]
|
||||
elif self._kind == SENSOR_KIND_POLLUTANT:
|
||||
|
||||
@@ -23,7 +23,6 @@ from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_NIGHT,
|
||||
STATE_ALARM_ARMING,
|
||||
STATE_ALARM_DISARMED,
|
||||
STATE_ALARM_PENDING,
|
||||
STATE_ALARM_TRIGGERED,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
@@ -143,13 +142,10 @@ async def async_attach_trigger(
|
||||
from_state = STATE_ALARM_DISARMED
|
||||
to_state = STATE_ALARM_ARMING
|
||||
elif config[CONF_TYPE] == "armed_home":
|
||||
from_state = STATE_ALARM_PENDING or STATE_ALARM_ARMING
|
||||
to_state = STATE_ALARM_ARMED_HOME
|
||||
elif config[CONF_TYPE] == "armed_away":
|
||||
from_state = STATE_ALARM_PENDING or STATE_ALARM_ARMING
|
||||
to_state = STATE_ALARM_ARMED_AWAY
|
||||
elif config[CONF_TYPE] == "armed_night":
|
||||
from_state = STATE_ALARM_PENDING or STATE_ALARM_ARMING
|
||||
to_state = STATE_ALARM_ARMED_NIGHT
|
||||
|
||||
state_config = {
|
||||
|
||||
@@ -45,6 +45,11 @@ class AbstractConfig(ABC):
|
||||
"""Return if proactive mode is enabled."""
|
||||
return self._unsub_proactive_report is not None
|
||||
|
||||
@callback
|
||||
@abstractmethod
|
||||
def user_identifier(self):
|
||||
"""Return an identifier for the user that represents this config."""
|
||||
|
||||
async def async_enable_proactive_mode(self):
|
||||
"""Enable proactive mode."""
|
||||
if self._unsub_proactive_report is None:
|
||||
|
||||
@@ -329,7 +329,7 @@ class AlexaEntity:
|
||||
"manufacturer": "Home Assistant",
|
||||
"model": self.entity.domain,
|
||||
"softwareVersion": __version__,
|
||||
"customIdentifier": self.entity_id,
|
||||
"customIdentifier": f"{self.config.user_identifier()}-{self.entity_id}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@ class AlexaConfig(AbstractConfig):
|
||||
"""Return config locale."""
|
||||
return self._config.get(CONF_LOCALE)
|
||||
|
||||
@core.callback
|
||||
def user_identifier(self):
|
||||
"""Return an identifier for the user that represents this config."""
|
||||
return ""
|
||||
|
||||
def should_expose(self, entity_id):
|
||||
"""If an entity should be exposed."""
|
||||
return self._config[CONF_FILTER](entity_id)
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.const import HTTP_ACCEPTED, MATCH_ALL, STATE_ON
|
||||
from homeassistant.core import State
|
||||
from homeassistant.helpers.significant_change import create_checker
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import API_CHANGE, Cause
|
||||
from .entities import ENTITY_ADAPTERS, generate_alexa_id
|
||||
from .const import API_CHANGE, DOMAIN, Cause
|
||||
from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id
|
||||
from .messages import AlexaResponse
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -25,7 +28,13 @@ async def async_enable_proactive_mode(hass, smart_home_config):
|
||||
# Validate we can get access token.
|
||||
await smart_home_config.async_get_access_token()
|
||||
|
||||
async def async_entity_state_listener(changed_entity, old_state, new_state):
|
||||
checker = await create_checker(hass, DOMAIN)
|
||||
|
||||
async def async_entity_state_listener(
|
||||
changed_entity: str,
|
||||
old_state: Optional[State],
|
||||
new_state: Optional[State],
|
||||
):
|
||||
if not hass.is_running:
|
||||
return
|
||||
|
||||
@@ -39,24 +48,43 @@ async def async_enable_proactive_mode(hass, smart_home_config):
|
||||
_LOGGER.debug("Not exposing %s because filtered by config", changed_entity)
|
||||
return
|
||||
|
||||
alexa_changed_entity = ENTITY_ADAPTERS[new_state.domain](
|
||||
alexa_changed_entity: AlexaEntity = ENTITY_ADAPTERS[new_state.domain](
|
||||
hass, smart_home_config, new_state
|
||||
)
|
||||
|
||||
# Determine how entity should be reported on
|
||||
should_report = False
|
||||
should_doorbell = False
|
||||
|
||||
for interface in alexa_changed_entity.interfaces():
|
||||
if interface.properties_proactively_reported():
|
||||
await async_send_changereport_message(
|
||||
hass, smart_home_config, alexa_changed_entity
|
||||
)
|
||||
return
|
||||
if not should_report and interface.properties_proactively_reported():
|
||||
should_report = True
|
||||
|
||||
if (
|
||||
interface.name() == "Alexa.DoorbellEventSource"
|
||||
and new_state.state == STATE_ON
|
||||
):
|
||||
await async_send_doorbell_event_message(
|
||||
hass, smart_home_config, alexa_changed_entity
|
||||
)
|
||||
return
|
||||
should_doorbell = True
|
||||
break
|
||||
|
||||
if not should_report and not should_doorbell:
|
||||
return
|
||||
|
||||
if not checker.async_is_significant_change(new_state):
|
||||
return
|
||||
|
||||
if should_doorbell:
|
||||
should_report = False
|
||||
|
||||
if should_report:
|
||||
await async_send_changereport_message(
|
||||
hass, smart_home_config, alexa_changed_entity
|
||||
)
|
||||
|
||||
elif should_doorbell:
|
||||
await async_send_doorbell_event_message(
|
||||
hass, smart_home_config, alexa_changed_entity
|
||||
)
|
||||
|
||||
return hass.helpers.event.async_track_state_change(
|
||||
MATCH_ALL, async_entity_state_listener
|
||||
@@ -76,13 +104,11 @@ async def async_send_changereport_message(
|
||||
|
||||
endpoint = alexa_entity.alexa_id()
|
||||
|
||||
# this sends all the properties of the Alexa Entity, whether they have
|
||||
# changed or not. this should be improved, and properties that have not
|
||||
# changed should be moved to the 'context' object
|
||||
properties = list(alexa_entity.serialize_properties())
|
||||
|
||||
payload = {
|
||||
API_CHANGE: {"cause": {"type": Cause.APP_INTERACTION}, "properties": properties}
|
||||
API_CHANGE: {
|
||||
"cause": {"type": Cause.APP_INTERACTION},
|
||||
"properties": list(alexa_entity.serialize_properties()),
|
||||
}
|
||||
}
|
||||
|
||||
message = AlexaResponse(name="ChangeReport", namespace="Alexa", payload=payload)
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"domain": "alpha_vantage",
|
||||
"name": "Alpha Vantage",
|
||||
"documentation": "https://www.home-assistant.io/integrations/alpha_vantage",
|
||||
"requirements": ["alpha_vantage==2.2.0"],
|
||||
"requirements": ["alpha_vantage==2.3.1"],
|
||||
"codeowners": ["@fabaff"]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
|
||||
"requirements": [
|
||||
"pyatv==0.7.5"
|
||||
"pyatv==0.7.6"
|
||||
],
|
||||
"zeroconf": [
|
||||
"_mediaremotetv._tcp.local.",
|
||||
|
||||
@@ -108,8 +108,6 @@ async def _run_client(hass, client, interval):
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception, aborting arcam client")
|
||||
return
|
||||
|
||||
@@ -43,17 +43,22 @@ _LOGGER = logging.getLogger(__name__)
|
||||
TWO_FA_REVALIDATE = "verify_configurator"
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_LOGIN_METHOD): vol.In(LOGIN_METHODS),
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_INSTALL_ID): cv.string,
|
||||
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
|
||||
}
|
||||
)
|
||||
},
|
||||
vol.All(
|
||||
cv.deprecated(DOMAIN),
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_LOGIN_METHOD): vol.In(LOGIN_METHODS),
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_INSTALL_ID): cv.string,
|
||||
vol.Optional(
|
||||
CONF_TIMEOUT, default=DEFAULT_TIMEOUT
|
||||
): cv.positive_int,
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from .subscriber import AugustSubscriberMixin
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ACTIVITY_STREAM_FETCH_LIMIT = 10
|
||||
ACTIVITY_CATCH_UP_FETCH_LIMIT = 1000
|
||||
ACTIVITY_CATCH_UP_FETCH_LIMIT = 2500
|
||||
|
||||
|
||||
class ActivityStream(AugustSubscriberMixin):
|
||||
@@ -102,11 +102,14 @@ class ActivityStream(AugustSubscriberMixin):
|
||||
def _process_newer_device_activities(self, activities):
|
||||
updated_device_ids = set()
|
||||
for activity in activities:
|
||||
self._latest_activities_by_id_type.setdefault(activity.device_id, {})
|
||||
device_id = activity.device_id
|
||||
activity_type = activity.activity_type
|
||||
|
||||
lastest_activity = self._latest_activities_by_id_type[
|
||||
activity.device_id
|
||||
].get(activity.activity_type)
|
||||
self._latest_activities_by_id_type.setdefault(device_id, {})
|
||||
|
||||
lastest_activity = self._latest_activities_by_id_type[device_id].get(
|
||||
activity_type
|
||||
)
|
||||
|
||||
# Ignore activities that are older than the latest one
|
||||
if (
|
||||
@@ -115,10 +118,8 @@ class ActivityStream(AugustSubscriberMixin):
|
||||
):
|
||||
continue
|
||||
|
||||
self._latest_activities_by_id_type[activity.device_id][
|
||||
activity.activity_type
|
||||
] = activity
|
||||
self._latest_activities_by_id_type[device_id][activity_type] = activity
|
||||
|
||||
updated_device_ids.add(activity.device_id)
|
||||
updated_device_ids.add(device_id)
|
||||
|
||||
return updated_device_ids
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
"domain": "august",
|
||||
"name": "August",
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"requirements": ["py-august==0.25.0"],
|
||||
"requirements": ["py-august==0.25.2"],
|
||||
"dependencies": ["configurator"],
|
||||
"codeowners": ["@bdraco"],
|
||||
"dhcp": [
|
||||
{"hostname":"connect","macaddress":"D86162*"},
|
||||
{"hostname":"connect","macaddress":"B8B7F1*"}
|
||||
],
|
||||
"config_flow": true
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from august.activity import ActivityType
|
||||
|
||||
from homeassistant.components.sensor import DEVICE_CLASS_BATTERY
|
||||
from homeassistant.const import ATTR_ENTITY_PICTURE, PERCENTAGE
|
||||
from homeassistant.const import ATTR_ENTITY_PICTURE, PERCENTAGE, STATE_UNAVAILABLE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_registry import async_get_registry
|
||||
@@ -157,8 +157,8 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, Entity):
|
||||
self._device_id, [ActivityType.LOCK_OPERATION]
|
||||
)
|
||||
|
||||
self._available = True
|
||||
if lock_activity is not None:
|
||||
self._available = True
|
||||
self._state = lock_activity.operated_by
|
||||
self._operated_remote = lock_activity.operated_remote
|
||||
self._operated_keypad = lock_activity.operated_keypad
|
||||
@@ -193,7 +193,7 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, Entity):
|
||||
await super().async_added_to_hass()
|
||||
|
||||
last_state = await self.async_get_last_state()
|
||||
if not last_state:
|
||||
if not last_state or last_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
self._state = last_state.state
|
||||
|
||||
@@ -404,6 +404,12 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
|
||||
await self.action_script.async_run(
|
||||
variables, trigger_context, started_action
|
||||
)
|
||||
except (vol.Invalid, HomeAssistantError) as err:
|
||||
self._logger.error(
|
||||
"Error while executing automation %s: %s",
|
||||
self.entity_id,
|
||||
err,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
self._logger.exception("While executing automation %s", self.entity_id)
|
||||
|
||||
@@ -462,8 +468,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
|
||||
) -> Optional[Callable[[], None]]:
|
||||
"""Set up the triggers."""
|
||||
|
||||
def log_cb(level, msg):
|
||||
self._logger.log(level, "%s %s", msg, self._name)
|
||||
def log_cb(level, msg, **kwargs):
|
||||
self._logger.log(level, "%s %s", msg, self._name, **kwargs)
|
||||
|
||||
return await async_initialize_triggers(
|
||||
cast(HomeAssistant, self.hass),
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "Amazon Web Services (AWS)",
|
||||
"documentation": "https://www.home-assistant.io/integrations/aws",
|
||||
"requirements": ["aiobotocore==0.11.1"],
|
||||
"codeowners": ["@awarecan"]
|
||||
"codeowners": []
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.const import CONF_DEVICE, CONF_MAC, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.entity_registry import async_migrate_entries
|
||||
|
||||
from .const import DOMAIN as AXIS_DOMAIN
|
||||
from .device import AxisNetworkDevice
|
||||
@@ -24,12 +27,6 @@ async def async_setup_entry(hass, config_entry):
|
||||
if not await device.async_setup():
|
||||
return False
|
||||
|
||||
# 0.104 introduced config entry unique id, this makes upgrading possible
|
||||
if config_entry.unique_id is None:
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, unique_id=device.api.vapix.serial_number
|
||||
)
|
||||
|
||||
hass.data[AXIS_DOMAIN][config_entry.unique_id] = device
|
||||
|
||||
await device.async_update_device_registry()
|
||||
@@ -52,9 +49,28 @@ async def async_migrate_entry(hass, config_entry):
|
||||
# Flatten configuration but keep old data if user rollbacks HASS prior to 0.106
|
||||
if config_entry.version == 1:
|
||||
config_entry.data = {**config_entry.data, **config_entry.data[CONF_DEVICE]}
|
||||
|
||||
config_entry.unique_id = config_entry.data[CONF_MAC]
|
||||
config_entry.version = 2
|
||||
|
||||
# Normalise MAC address of device which also affects entity unique IDs
|
||||
if config_entry.version == 2:
|
||||
old_unique_id = config_entry.unique_id
|
||||
new_unique_id = format_mac(old_unique_id)
|
||||
|
||||
@callback
|
||||
def update_unique_id(entity_entry):
|
||||
"""Update unique ID of entity entry."""
|
||||
return {
|
||||
"new_unique_id": entity_entry.unique_id.replace(
|
||||
old_unique_id, new_unique_id
|
||||
)
|
||||
}
|
||||
|
||||
await async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
|
||||
|
||||
config_entry.unique_id = new_unique_id
|
||||
config_entry.version = 3
|
||||
|
||||
_LOGGER.info("Migration to version %s successful", config_entry.version)
|
||||
|
||||
return True
|
||||
|
||||
@@ -30,7 +30,7 @@ class AxisEntityBase(Entity):
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return a device description for device registry."""
|
||||
return {"identifiers": {(AXIS_DOMAIN, self.device.serial)}}
|
||||
return {"identifiers": {(AXIS_DOMAIN, self.device.unique_id)}}
|
||||
|
||||
@callback
|
||||
def update_callback(self, no_delay=None):
|
||||
@@ -73,4 +73,4 @@ class AxisEventBase(AxisEntityBase):
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique identifier for this device."""
|
||||
return f"{self.device.serial}-{self.event.topic}-{self.event.id}"
|
||||
return f"{self.device.unique_id}-{self.event.topic}-{self.event.id}"
|
||||
|
||||
@@ -7,10 +7,12 @@ from axis.event_stream import (
|
||||
CLASS_LIGHT,
|
||||
CLASS_MOTION,
|
||||
CLASS_OUTPUT,
|
||||
CLASS_PTZ,
|
||||
CLASS_SOUND,
|
||||
FenceGuard,
|
||||
LoiteringGuard,
|
||||
MotionGuard,
|
||||
ObjectAnalytics,
|
||||
Vmd4,
|
||||
)
|
||||
|
||||
@@ -46,7 +48,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Add binary sensor from Axis device."""
|
||||
event = device.api.event[event_id]
|
||||
|
||||
if event.CLASS != CLASS_OUTPUT and not (
|
||||
if event.CLASS not in (CLASS_OUTPUT, CLASS_PTZ) and not (
|
||||
event.CLASS == CLASS_LIGHT and event.TYPE == "Light"
|
||||
):
|
||||
async_add_entities([AxisBinarySensor(event, device)])
|
||||
@@ -101,7 +103,7 @@ class AxisBinarySensor(AxisEventBase, BinarySensorEntity):
|
||||
"""Return the name of the event."""
|
||||
if (
|
||||
self.event.CLASS == CLASS_INPUT
|
||||
and self.event.id
|
||||
and self.event.id in self.device.api.vapix.ports
|
||||
and self.device.api.vapix.ports[self.event.id].name
|
||||
):
|
||||
return (
|
||||
@@ -114,6 +116,7 @@ class AxisBinarySensor(AxisEventBase, BinarySensorEntity):
|
||||
(FenceGuard, self.device.api.vapix.fence_guard),
|
||||
(LoiteringGuard, self.device.api.vapix.loitering_guard),
|
||||
(MotionGuard, self.device.api.vapix.motion_guard),
|
||||
(ObjectAnalytics, self.device.api.vapix.object_analytics),
|
||||
(Vmd4, self.device.api.vapix.vmd4),
|
||||
):
|
||||
if (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Support for Axis camera streaming."""
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from homeassistant.components.camera import SUPPORT_STREAM
|
||||
from homeassistant.components.mjpeg.camera import (
|
||||
CONF_MJPEG_URL,
|
||||
@@ -11,14 +13,13 @@ from homeassistant.const import (
|
||||
CONF_AUTHENTICATION,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
HTTP_DIGEST_AUTHENTICATION,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from .axis_base import AxisEntityBase
|
||||
from .const import DEFAULT_STREAM_PROFILE, DOMAIN as AXIS_DOMAIN
|
||||
from .const import DEFAULT_STREAM_PROFILE, DEFAULT_VIDEO_SOURCE, DOMAIN as AXIS_DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
@@ -41,9 +42,9 @@ class AxisCamera(AxisEntityBase, MjpegCamera):
|
||||
AxisEntityBase.__init__(self, device)
|
||||
|
||||
config = {
|
||||
CONF_NAME: device.config_entry.data[CONF_NAME],
|
||||
CONF_USERNAME: device.config_entry.data[CONF_USERNAME],
|
||||
CONF_PASSWORD: device.config_entry.data[CONF_PASSWORD],
|
||||
CONF_NAME: device.name,
|
||||
CONF_USERNAME: device.username,
|
||||
CONF_PASSWORD: device.password,
|
||||
CONF_MJPEG_URL: self.mjpeg_source,
|
||||
CONF_STILL_IMAGE_URL: self.image_source,
|
||||
CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION,
|
||||
@@ -61,38 +62,55 @@ class AxisCamera(AxisEntityBase, MjpegCamera):
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
def supported_features(self) -> int:
|
||||
"""Return supported features."""
|
||||
return SUPPORT_STREAM
|
||||
|
||||
def _new_address(self):
|
||||
def _new_address(self) -> None:
|
||||
"""Set new device address for video stream."""
|
||||
self._mjpeg_url = self.mjpeg_source
|
||||
self._still_image_url = self.image_source
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique identifier for this device."""
|
||||
return f"{self.device.serial}-camera"
|
||||
return f"{self.device.unique_id}-camera"
|
||||
|
||||
@property
|
||||
def image_source(self):
|
||||
def image_source(self) -> str:
|
||||
"""Return still image URL for device."""
|
||||
return f"http://{self.device.host}:{self.device.config_entry.data[CONF_PORT]}/axis-cgi/jpg/image.cgi"
|
||||
options = self.generate_options(skip_stream_profile=True)
|
||||
return f"http://{self.device.host}:{self.device.port}/axis-cgi/jpg/image.cgi{options}"
|
||||
|
||||
@property
|
||||
def mjpeg_source(self):
|
||||
def mjpeg_source(self) -> str:
|
||||
"""Return mjpeg URL for device."""
|
||||
options = ""
|
||||
if self.device.option_stream_profile != DEFAULT_STREAM_PROFILE:
|
||||
options = f"?&streamprofile={self.device.option_stream_profile}"
|
||||
options = self.generate_options()
|
||||
return f"http://{self.device.host}:{self.device.port}/axis-cgi/mjpg/video.cgi{options}"
|
||||
|
||||
return f"http://{self.device.host}:{self.device.config_entry.data[CONF_PORT]}/axis-cgi/mjpg/video.cgi{options}"
|
||||
|
||||
async def stream_source(self):
|
||||
async def stream_source(self) -> str:
|
||||
"""Return the stream source."""
|
||||
options = ""
|
||||
if self.device.option_stream_profile != DEFAULT_STREAM_PROFILE:
|
||||
options = f"&streamprofile={self.device.option_stream_profile}"
|
||||
options = self.generate_options(add_video_codec_h264=True)
|
||||
return f"rtsp://{self.device.username}:{self.device.password}@{self.device.host}/axis-media/media.amp{options}"
|
||||
|
||||
return f"rtsp://{self.device.config_entry.data[CONF_USERNAME]}:{self.device.config_entry.data[CONF_PASSWORD]}@{self.device.host}/axis-media/media.amp?videocodec=h264{options}"
|
||||
def generate_options(
|
||||
self, skip_stream_profile: bool = False, add_video_codec_h264: bool = False
|
||||
) -> str:
|
||||
"""Generate options for video stream."""
|
||||
options_dict = {}
|
||||
|
||||
if add_video_codec_h264:
|
||||
options_dict["videocodec"] = "h264"
|
||||
|
||||
if (
|
||||
not skip_stream_profile
|
||||
and self.device.option_stream_profile != DEFAULT_STREAM_PROFILE
|
||||
):
|
||||
options_dict["streamprofile"] = self.device.option_stream_profile
|
||||
|
||||
if self.device.option_video_source != DEFAULT_VIDEO_SOURCE:
|
||||
options_dict["camera"] = self.device.option_video_source
|
||||
|
||||
if not options_dict:
|
||||
return ""
|
||||
return f"?{urlencode(options_dict)}"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Config flow to configure Axis devices."""
|
||||
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS
|
||||
from homeassistant.config_entries import SOURCE_IGNORE
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
@@ -15,34 +17,28 @@ from homeassistant.const import (
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.util.network import is_link_local
|
||||
|
||||
from .const import (
|
||||
CONF_MODEL,
|
||||
CONF_STREAM_PROFILE,
|
||||
CONF_VIDEO_SOURCE,
|
||||
DEFAULT_STREAM_PROFILE,
|
||||
DEFAULT_VIDEO_SOURCE,
|
||||
DOMAIN as AXIS_DOMAIN,
|
||||
)
|
||||
from .device import get_device
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
|
||||
AXIS_OUI = {"00408C", "ACCC8E", "B8A44F"}
|
||||
|
||||
CONFIG_FILE = "axis.conf"
|
||||
|
||||
EVENT_TYPES = ["motion", "vmd3", "pir", "sound", "daynight", "tampering", "input"]
|
||||
|
||||
PLATFORMS = ["camera"]
|
||||
|
||||
AXIS_INCLUDE = EVENT_TYPES + PLATFORMS
|
||||
|
||||
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f"}
|
||||
DEFAULT_PORT = 80
|
||||
|
||||
|
||||
class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN):
|
||||
"""Handle a Axis config flow."""
|
||||
|
||||
VERSION = 2
|
||||
VERSION = 3
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
|
||||
|
||||
@staticmethod
|
||||
@@ -56,6 +52,7 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN):
|
||||
self.device_config = {}
|
||||
self.discovery_schema = {}
|
||||
self.import_schema = {}
|
||||
self.serial = None
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a Axis config flow start.
|
||||
@@ -74,12 +71,15 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN):
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
await self.async_set_unique_id(device.vapix.serial_number)
|
||||
self.serial = device.vapix.serial_number
|
||||
await self.async_set_unique_id(format_mac(self.serial))
|
||||
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -88,7 +88,6 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN):
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_MAC: device.vapix.serial_number,
|
||||
CONF_MODEL: device.vapix.product_number,
|
||||
}
|
||||
|
||||
@@ -134,39 +133,88 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN):
|
||||
|
||||
self.device_config[CONF_NAME] = name
|
||||
|
||||
title = f"{model} - {self.device_config[CONF_MAC]}"
|
||||
title = f"{model} - {self.serial}"
|
||||
return self.async_create_entry(title=title, data=self.device_config)
|
||||
|
||||
async def async_step_zeroconf(self, discovery_info):
|
||||
"""Prepare configuration for a discovered Axis device."""
|
||||
serial_number = discovery_info["properties"]["macaddress"]
|
||||
async def async_step_reauth(self, device_config: dict):
|
||||
"""Trigger a reauthentication flow."""
|
||||
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
||||
self.context["title_placeholders"] = {
|
||||
CONF_NAME: device_config[CONF_NAME],
|
||||
CONF_HOST: device_config[CONF_HOST],
|
||||
}
|
||||
|
||||
if serial_number[:6] not in AXIS_OUI:
|
||||
self.discovery_schema = {
|
||||
vol.Required(CONF_HOST, default=device_config[CONF_HOST]): str,
|
||||
vol.Required(CONF_USERNAME, default=device_config[CONF_USERNAME]): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_PORT, default=device_config[CONF_PORT]): int,
|
||||
}
|
||||
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_dhcp(self, discovery_info: dict):
|
||||
"""Prepare configuration for a DHCP discovered Axis device."""
|
||||
return await self._process_discovered_device(
|
||||
{
|
||||
CONF_HOST: discovery_info[IP_ADDRESS],
|
||||
CONF_MAC: format_mac(discovery_info.get(MAC_ADDRESS)),
|
||||
CONF_NAME: discovery_info.get(HOSTNAME),
|
||||
CONF_PORT: DEFAULT_PORT,
|
||||
}
|
||||
)
|
||||
|
||||
async def async_step_ssdp(self, discovery_info: dict):
|
||||
"""Prepare configuration for a SSDP discovered Axis device."""
|
||||
url = urlsplit(discovery_info["presentationURL"])
|
||||
return await self._process_discovered_device(
|
||||
{
|
||||
CONF_HOST: url.hostname,
|
||||
CONF_MAC: format_mac(discovery_info["serialNumber"]),
|
||||
CONF_NAME: f"{discovery_info['friendlyName']}",
|
||||
CONF_PORT: url.port,
|
||||
}
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(self, discovery_info: dict):
|
||||
"""Prepare configuration for a Zeroconf discovered Axis device."""
|
||||
return await self._process_discovered_device(
|
||||
{
|
||||
CONF_HOST: discovery_info[CONF_HOST],
|
||||
CONF_MAC: format_mac(discovery_info["properties"]["macaddress"]),
|
||||
CONF_NAME: discovery_info["name"].split(".", 1)[0],
|
||||
CONF_PORT: discovery_info[CONF_PORT],
|
||||
}
|
||||
)
|
||||
|
||||
async def _process_discovered_device(self, device: dict):
|
||||
"""Prepare configuration for a discovered Axis device."""
|
||||
if device[CONF_MAC][:8] not in AXIS_OUI:
|
||||
return self.async_abort(reason="not_axis_device")
|
||||
|
||||
if is_link_local(ip_address(discovery_info[CONF_HOST])):
|
||||
if is_link_local(ip_address(device[CONF_HOST])):
|
||||
return self.async_abort(reason="link_local_address")
|
||||
|
||||
await self.async_set_unique_id(serial_number)
|
||||
await self.async_set_unique_id(device[CONF_MAC])
|
||||
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
CONF_HOST: discovery_info[CONF_HOST],
|
||||
CONF_PORT: discovery_info[CONF_PORT],
|
||||
CONF_HOST: device[CONF_HOST],
|
||||
CONF_PORT: device[CONF_PORT],
|
||||
}
|
||||
)
|
||||
|
||||
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
||||
self.context["title_placeholders"] = {
|
||||
CONF_NAME: discovery_info["hostname"][:-7],
|
||||
CONF_HOST: discovery_info[CONF_HOST],
|
||||
CONF_NAME: device[CONF_NAME],
|
||||
CONF_HOST: device[CONF_HOST],
|
||||
}
|
||||
|
||||
self.discovery_schema = {
|
||||
vol.Required(CONF_HOST, default=discovery_info[CONF_HOST]): str,
|
||||
vol.Required(CONF_HOST, default=device[CONF_HOST]): str,
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_PORT, default=discovery_info[CONF_PORT]): int,
|
||||
vol.Required(CONF_PORT, default=device[CONF_PORT]): int,
|
||||
}
|
||||
|
||||
return await self.async_step_user()
|
||||
@@ -187,22 +235,44 @@ class AxisOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
return await self.async_step_configure_stream()
|
||||
|
||||
async def async_step_configure_stream(self, user_input=None):
|
||||
"""Manage the Axis device options."""
|
||||
"""Manage the Axis device stream options."""
|
||||
if user_input is not None:
|
||||
self.options.update(user_input)
|
||||
return self.async_create_entry(title="", data=self.options)
|
||||
|
||||
profiles = [DEFAULT_STREAM_PROFILE]
|
||||
for profile in self.device.api.vapix.streaming_profiles:
|
||||
profiles.append(profile.name)
|
||||
schema = {}
|
||||
|
||||
vapix = self.device.api.vapix
|
||||
|
||||
# Stream profiles
|
||||
|
||||
if vapix.params.stream_profiles_max_groups > 0:
|
||||
|
||||
stream_profiles = [DEFAULT_STREAM_PROFILE]
|
||||
for profile in vapix.streaming_profiles:
|
||||
stream_profiles.append(profile.name)
|
||||
|
||||
schema[
|
||||
vol.Optional(
|
||||
CONF_STREAM_PROFILE, default=self.device.option_stream_profile
|
||||
)
|
||||
] = vol.In(stream_profiles)
|
||||
|
||||
# Video sources
|
||||
|
||||
if vapix.params.image_nbrofviews > 0:
|
||||
await vapix.params.update_image()
|
||||
|
||||
video_sources = {DEFAULT_VIDEO_SOURCE: DEFAULT_VIDEO_SOURCE}
|
||||
for idx, video_source in vapix.params.image_sources.items():
|
||||
if not video_source["Enabled"]:
|
||||
continue
|
||||
video_sources[idx + 1] = video_source["Name"]
|
||||
|
||||
schema[
|
||||
vol.Optional(CONF_VIDEO_SOURCE, default=self.device.option_video_source)
|
||||
] = vol.In(video_sources)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure_stream",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_STREAM_PROFILE, default=self.device.option_stream_profile
|
||||
): vol.In(profiles)
|
||||
}
|
||||
),
|
||||
step_id="configure_stream", data_schema=vol.Schema(schema)
|
||||
)
|
||||
|
||||
@@ -15,9 +15,11 @@ ATTR_MANUFACTURER = "Axis Communications AB"
|
||||
CONF_EVENTS = "events"
|
||||
CONF_MODEL = "model"
|
||||
CONF_STREAM_PROFILE = "stream_profile"
|
||||
CONF_VIDEO_SOURCE = "video_source"
|
||||
|
||||
DEFAULT_EVENTS = True
|
||||
DEFAULT_STREAM_PROFILE = "No stream profile"
|
||||
DEFAULT_TRIGGER_TIME = 0
|
||||
DEFAULT_VIDEO_SOURCE = "No video source"
|
||||
|
||||
PLATFORMS = [BINARY_SENSOR_DOMAIN, CAMERA_DOMAIN, LIGHT_DOMAIN, SWITCH_DOMAIN]
|
||||
|
||||
@@ -13,6 +13,7 @@ from axis.streammanager import SIGNAL_PLAYING, STATE_STOPPED
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN
|
||||
from homeassistant.components.mqtt.models import Message
|
||||
from homeassistant.config_entries import SOURCE_REAUTH
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
@@ -25,6 +26,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.setup import async_when_setup
|
||||
|
||||
from .const import (
|
||||
@@ -32,9 +34,11 @@ from .const import (
|
||||
CONF_EVENTS,
|
||||
CONF_MODEL,
|
||||
CONF_STREAM_PROFILE,
|
||||
CONF_VIDEO_SOURCE,
|
||||
DEFAULT_EVENTS,
|
||||
DEFAULT_STREAM_PROFILE,
|
||||
DEFAULT_TRIGGER_TIME,
|
||||
DEFAULT_VIDEO_SOURCE,
|
||||
DOMAIN as AXIS_DOMAIN,
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
@@ -59,9 +63,24 @@ class AxisNetworkDevice:
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
"""Return the host of this device."""
|
||||
"""Return the host address of this device."""
|
||||
return self.config_entry.data[CONF_HOST]
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
"""Return the HTTP port of this device."""
|
||||
return self.config_entry.data[CONF_PORT]
|
||||
|
||||
@property
|
||||
def username(self):
|
||||
"""Return the username of this device."""
|
||||
return self.config_entry.data[CONF_USERNAME]
|
||||
|
||||
@property
|
||||
def password(self):
|
||||
"""Return the password of this device."""
|
||||
return self.config_entry.data[CONF_PASSWORD]
|
||||
|
||||
@property
|
||||
def model(self):
|
||||
"""Return the model of this device."""
|
||||
@@ -73,8 +92,8 @@ class AxisNetworkDevice:
|
||||
return self.config_entry.data[CONF_NAME]
|
||||
|
||||
@property
|
||||
def serial(self):
|
||||
"""Return the serial number of this device."""
|
||||
def unique_id(self):
|
||||
"""Return the unique ID (serial number) of this device."""
|
||||
return self.config_entry.unique_id
|
||||
|
||||
# Options
|
||||
@@ -96,22 +115,27 @@ class AxisNetworkDevice:
|
||||
"""Config entry option defining minimum number of seconds to keep trigger high."""
|
||||
return self.config_entry.options.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME)
|
||||
|
||||
@property
|
||||
def option_video_source(self):
|
||||
"""Config entry option defining what video source camera platform should use."""
|
||||
return self.config_entry.options.get(CONF_VIDEO_SOURCE, DEFAULT_VIDEO_SOURCE)
|
||||
|
||||
# Signals
|
||||
|
||||
@property
|
||||
def signal_reachable(self):
|
||||
"""Device specific event to signal a change in connection status."""
|
||||
return f"axis_reachable_{self.serial}"
|
||||
return f"axis_reachable_{self.unique_id}"
|
||||
|
||||
@property
|
||||
def signal_new_event(self):
|
||||
"""Device specific event to signal new device event available."""
|
||||
return f"axis_new_event_{self.serial}"
|
||||
return f"axis_new_event_{self.unique_id}"
|
||||
|
||||
@property
|
||||
def signal_new_address(self):
|
||||
"""Device specific event to signal a change in device address."""
|
||||
return f"axis_new_address_{self.serial}"
|
||||
return f"axis_new_address_{self.unique_id}"
|
||||
|
||||
# Callbacks
|
||||
|
||||
@@ -150,8 +174,8 @@ class AxisNetworkDevice:
|
||||
device_registry = await self.hass.helpers.device_registry.async_get_registry()
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
connections={(CONNECTION_NETWORK_MAC, self.serial)},
|
||||
identifiers={(AXIS_DOMAIN, self.serial)},
|
||||
connections={(CONNECTION_NETWORK_MAC, self.unique_id)},
|
||||
identifiers={(AXIS_DOMAIN, self.unique_id)},
|
||||
manufacturer=ATTR_MANUFACTURER,
|
||||
model=f"{self.model} {self.product_type}",
|
||||
name=self.name,
|
||||
@@ -168,7 +192,9 @@ class AxisNetworkDevice:
|
||||
|
||||
if status.get("data", {}).get("status", {}).get("state") == "active":
|
||||
self.listeners.append(
|
||||
await mqtt.async_subscribe(hass, f"{self.serial}/#", self.mqtt_message)
|
||||
await mqtt.async_subscribe(
|
||||
hass, f"{self.api.vapix.serial_number}/#", self.mqtt_message
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -177,7 +203,7 @@ class AxisNetworkDevice:
|
||||
self.disconnect_from_stream()
|
||||
|
||||
event = mqtt_json_to_event(message.payload)
|
||||
self.api.event.process_event(event)
|
||||
self.api.event.update([event])
|
||||
|
||||
# Setup and teardown methods
|
||||
|
||||
@@ -186,17 +212,23 @@ class AxisNetworkDevice:
|
||||
try:
|
||||
self.api = await get_device(
|
||||
self.hass,
|
||||
host=self.config_entry.data[CONF_HOST],
|
||||
port=self.config_entry.data[CONF_PORT],
|
||||
username=self.config_entry.data[CONF_USERNAME],
|
||||
password=self.config_entry.data[CONF_PASSWORD],
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
)
|
||||
|
||||
except CannotConnect as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
LOGGER.error("Unknown error connecting with Axis device on %s", self.host)
|
||||
except AuthenticationRequired:
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.flow.async_init(
|
||||
AXIS_DOMAIN,
|
||||
context={"source": SOURCE_REAUTH},
|
||||
data=self.config_entry.data,
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
self.fw_version = self.api.vapix.firmware_version
|
||||
@@ -239,12 +271,10 @@ class AxisNetworkDevice:
|
||||
async def shutdown(self, event):
|
||||
"""Stop the event stream."""
|
||||
self.disconnect_from_stream()
|
||||
await self.api.vapix.close()
|
||||
|
||||
async def async_reset(self):
|
||||
"""Reset this device to default state."""
|
||||
self.disconnect_from_stream()
|
||||
await self.api.vapix.close()
|
||||
|
||||
unload_ok = all(
|
||||
await asyncio.gather(
|
||||
@@ -267,9 +297,10 @@ class AxisNetworkDevice:
|
||||
|
||||
async def get_device(hass, host, port, username, password):
|
||||
"""Create a Axis device."""
|
||||
session = get_async_client(hass, verify_ssl=False)
|
||||
|
||||
device = axis.AxisDevice(
|
||||
Configuration(host, port=port, username=username, password=password)
|
||||
Configuration(session, host, port=port, username=username, password=password)
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -280,15 +311,12 @@ async def get_device(hass, host, port, username, password):
|
||||
|
||||
except axis.Unauthorized as err:
|
||||
LOGGER.warning("Connected to device at %s but not registered.", host)
|
||||
await device.vapix.close()
|
||||
raise AuthenticationRequired from err
|
||||
|
||||
except (asyncio.TimeoutError, axis.RequestError) as err:
|
||||
LOGGER.error("Error connecting to the Axis device at %s", host)
|
||||
await device.vapix.close()
|
||||
raise CannotConnect from err
|
||||
|
||||
except axis.AxisException as err:
|
||||
LOGGER.exception("Unknown Axis communication error occurred")
|
||||
await device.vapix.close()
|
||||
raise AuthenticationRequired from err
|
||||
|
||||
@@ -18,7 +18,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up a Axis light."""
|
||||
device = hass.data[AXIS_DOMAIN][config_entry.unique_id]
|
||||
|
||||
if not device.api.vapix.light_control:
|
||||
if (
|
||||
device.api.vapix.light_control is None
|
||||
or len(device.api.vapix.light_control) == 0
|
||||
):
|
||||
return
|
||||
|
||||
@callback
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
"name": "Axis",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/axis",
|
||||
"requirements": ["axis==41"],
|
||||
"requirements": ["axis==43"],
|
||||
"dhcp": [
|
||||
{ "hostname": "axis-00408c*", "macaddress": "00408C*" },
|
||||
{ "hostname": "axis-accc8e*", "macaddress": "ACCC8E*" },
|
||||
{ "hostname": "axis-b8a44f*", "macaddress": "B8A44F*" }
|
||||
],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
}
|
||||
],
|
||||
"zeroconf": [
|
||||
{ "type": "_axis-video._tcp.local.", "macaddress": "00408C*" },
|
||||
{ "type": "_axis-video._tcp.local.", "macaddress": "ACCC8E*" },
|
||||
{ "type": "_axis-video._tcp.local.", "macaddress": "B8A44F*" }
|
||||
],
|
||||
"after_dependencies": ["mqtt"],
|
||||
"codeowners": ["@Kane610"]
|
||||
"codeowners": ["@Kane610"],
|
||||
"quality_scale": "platinum"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "Axis device: {name} ({host})",
|
||||
"flow_title": "{name} ({host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up Axis device",
|
||||
|
||||
@@ -36,6 +36,7 @@ def _send_blink_2fa_pin(auth, pin):
|
||||
"""Send 2FA pin to blink servers."""
|
||||
blink = Blink()
|
||||
blink.auth = auth
|
||||
blink.setup_login_ids()
|
||||
blink.setup_urls()
|
||||
return auth.send_auth_key(blink, pin)
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ def setup_scanner(hass, config, see, discovery_info=None):
|
||||
):
|
||||
handle = None
|
||||
try:
|
||||
adapter.start(reset_on_start=True)
|
||||
adapter.start(reset_on_start=False)
|
||||
_LOGGER.debug("Reading battery for Bluetooth LE device %s", mac)
|
||||
bt_device = adapter.connect(mac)
|
||||
# Try to get the handle; it will raise a BLEError exception if not available
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "bmw_connected_drive",
|
||||
"name": "BMW Connected Drive",
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"requirements": ["bimmer_connected==0.7.13"],
|
||||
"requirements": ["bimmer_connected==0.7.14"],
|
||||
"codeowners": ["@gerard33", "@rikroe"],
|
||||
"config_flow": true
|
||||
}
|
||||
|
||||
@@ -57,9 +57,7 @@ class BroadlinkDevice:
|
||||
Triggered when the device is renamed on the frontend.
|
||||
"""
|
||||
device_registry = await dr.async_get_registry(hass)
|
||||
device_entry = device_registry.async_get_device(
|
||||
{(DOMAIN, entry.unique_id)}, set()
|
||||
)
|
||||
device_entry = device_registry.async_get_device({(DOMAIN, entry.unique_id)})
|
||||
device_registry.async_update_device(device_entry.id, name=entry.title)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ from homeassistant.components.remote import (
|
||||
ATTR_DEVICE,
|
||||
ATTR_NUM_REPEATS,
|
||||
DEFAULT_DELAY_SECS,
|
||||
DOMAIN as RM_DOMAIN,
|
||||
PLATFORM_SCHEMA,
|
||||
SERVICE_DELETE_COMMAND,
|
||||
SUPPORT_DELETE_COMMAND,
|
||||
SUPPORT_LEARN_COMMAND,
|
||||
RemoteEntity,
|
||||
)
|
||||
@@ -48,6 +51,8 @@ COMMAND_TYPES = [COMMAND_TYPE_IR, COMMAND_TYPE_RF]
|
||||
|
||||
CODE_STORAGE_VERSION = 1
|
||||
FLAG_STORAGE_VERSION = 1
|
||||
|
||||
CODE_SAVE_DELAY = 15
|
||||
FLAG_SAVE_DELAY = 15
|
||||
|
||||
COMMAND_SCHEMA = vol.Schema(
|
||||
@@ -74,6 +79,10 @@ SERVICE_LEARN_SCHEMA = COMMAND_SCHEMA.extend(
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_DELETE_SCHEMA = COMMAND_SCHEMA.extend(
|
||||
{vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1))}
|
||||
)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{vol.Required(CONF_HOST): cv.string}, extra=vol.ALLOW_EXTRA
|
||||
)
|
||||
@@ -149,7 +158,7 @@ class BroadlinkRemote(RemoteEntity, RestoreEntity):
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
return SUPPORT_LEARN_COMMAND
|
||||
return SUPPORT_LEARN_COMMAND | SUPPORT_DELETE_COMMAND
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
@@ -196,6 +205,11 @@ class BroadlinkRemote(RemoteEntity, RestoreEntity):
|
||||
except ValueError as err:
|
||||
raise ValueError("Invalid code") from err
|
||||
|
||||
@callback
|
||||
def get_codes(self):
|
||||
"""Return a dictionary of codes."""
|
||||
return self._codes
|
||||
|
||||
@callback
|
||||
def get_flags(self):
|
||||
"""Return a dictionary of toggle flags.
|
||||
@@ -434,3 +448,52 @@ class BroadlinkRemote(RemoteEntity, RestoreEntity):
|
||||
self.hass.components.persistent_notification.async_dismiss(
|
||||
notification_id="learn_command"
|
||||
)
|
||||
|
||||
async def async_delete_command(self, **kwargs):
|
||||
"""Delete a list of commands from a remote."""
|
||||
kwargs = SERVICE_DELETE_SCHEMA(kwargs)
|
||||
commands = kwargs[ATTR_COMMAND]
|
||||
device = kwargs[ATTR_DEVICE]
|
||||
service = f"{RM_DOMAIN}.{SERVICE_DELETE_COMMAND}"
|
||||
|
||||
if not self._state:
|
||||
_LOGGER.warning(
|
||||
"%s canceled: %s entity is turned off",
|
||||
service,
|
||||
self.entity_id,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
codes = self._codes[device]
|
||||
except KeyError as err:
|
||||
err_msg = f"Device not found: {repr(device)}"
|
||||
_LOGGER.error("Failed to call %s. %s", service, err_msg)
|
||||
raise ValueError(err_msg) from err
|
||||
|
||||
cmds_not_found = []
|
||||
for command in commands:
|
||||
try:
|
||||
del codes[command]
|
||||
except KeyError:
|
||||
cmds_not_found.append(command)
|
||||
|
||||
if cmds_not_found:
|
||||
if len(cmds_not_found) == 1:
|
||||
err_msg = f"Command not found: {repr(cmds_not_found[0])}"
|
||||
else:
|
||||
err_msg = f"Commands not found: {repr(cmds_not_found)}"
|
||||
|
||||
if len(cmds_not_found) == len(commands):
|
||||
_LOGGER.error("Failed to call %s. %s", service, err_msg)
|
||||
raise ValueError(err_msg)
|
||||
|
||||
_LOGGER.error("Error during %s. %s", service, err_msg)
|
||||
|
||||
# Clean up
|
||||
if not codes:
|
||||
del self._codes[device]
|
||||
if self._flags.pop(device, None) is not None:
|
||||
self._flag_storage.async_delay_save(self.get_flags, FLAG_SAVE_DELAY)
|
||||
|
||||
self._code_storage.async_delay_save(self.get_codes, CODE_SAVE_DELAY)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/brother",
|
||||
"codeowners": ["@bieniu"],
|
||||
"requirements": ["brother==0.1.20"],
|
||||
"zeroconf": [{"type": "_printer._tcp.local.", "name":"Brother*"}],
|
||||
"zeroconf": [{ "type": "_printer._tcp.local.", "name": "brother*" }],
|
||||
"config_flow": true,
|
||||
"quality_scale": "platinum"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"domain": "caldav",
|
||||
"name": "CalDAV",
|
||||
"documentation": "https://www.home-assistant.io/integrations/caldav",
|
||||
"requirements": ["caldav==0.6.1"],
|
||||
"requirements": ["caldav==0.7.1"],
|
||||
"codeowners": []
|
||||
}
|
||||
|
||||
@@ -127,11 +127,14 @@ class CanaryCamera(CoordinatorEntity, Camera):
|
||||
async def async_camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
await self.hass.async_add_executor_job(self.renew_live_stream_session)
|
||||
live_stream_url = await self.hass.async_add_executor_job(
|
||||
getattr, self._live_stream_session, "live_stream_url"
|
||||
)
|
||||
|
||||
ffmpeg = ImageFrame(self._ffmpeg.binary)
|
||||
image = await asyncio.shield(
|
||||
ffmpeg.get_image(
|
||||
self._live_stream_session.live_stream_url,
|
||||
live_stream_url,
|
||||
output_format=IMAGE_JPEG,
|
||||
extra_cmd=self._ffmpeg_arguments,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "canary",
|
||||
"name": "Canary",
|
||||
"documentation": "https://www.home-assistant.io/integrations/canary",
|
||||
"requirements": ["py-canary==0.5.0"],
|
||||
"requirements": ["py-canary==0.5.1"],
|
||||
"dependencies": ["ffmpeg"],
|
||||
"codeowners": [],
|
||||
"config_flow": true
|
||||
|
||||
@@ -25,6 +25,7 @@ def discover_chromecast(hass: HomeAssistant, info: ChromecastInfo):
|
||||
_LOGGER.error("Discovered chromecast without uuid %s", info)
|
||||
return
|
||||
|
||||
info = info.fill_out_missing_chromecast_info()
|
||||
if info.uuid in hass.data[KNOWN_CHROMECAST_INFO_KEY]:
|
||||
_LOGGER.debug("Discovered update for known chromecast %s", info)
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Helpers to deal with Cast devices."""
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional
|
||||
|
||||
import attr
|
||||
from pychromecast import dial
|
||||
from pychromecast.const import CAST_MANUFACTURERS
|
||||
|
||||
from .const import DEFAULT_PORT
|
||||
@@ -20,8 +21,10 @@ class ChromecastInfo:
|
||||
uuid: Optional[str] = attr.ib(
|
||||
converter=attr.converters.optional(str), default=None
|
||||
) # always convert UUID to string if not None
|
||||
_manufacturer = attr.ib(type=Optional[str], default=None)
|
||||
model_name: str = attr.ib(default="")
|
||||
friendly_name: Optional[str] = attr.ib(default=None)
|
||||
is_dynamic_group = attr.ib(type=Optional[bool], default=None)
|
||||
|
||||
@property
|
||||
def is_audio_group(self) -> bool:
|
||||
@@ -29,17 +32,84 @@ class ChromecastInfo:
|
||||
return self.port != DEFAULT_PORT
|
||||
|
||||
@property
|
||||
def host_port(self) -> Tuple[str, int]:
|
||||
"""Return the host+port tuple."""
|
||||
return self.host, self.port
|
||||
def is_information_complete(self) -> bool:
|
||||
"""Return if all information is filled out."""
|
||||
want_dynamic_group = self.is_audio_group
|
||||
have_dynamic_group = self.is_dynamic_group is not None
|
||||
have_all_except_dynamic_group = all(
|
||||
attr.astuple(
|
||||
self,
|
||||
filter=attr.filters.exclude(
|
||||
attr.fields(ChromecastInfo).is_dynamic_group
|
||||
),
|
||||
)
|
||||
)
|
||||
return have_all_except_dynamic_group and (
|
||||
not want_dynamic_group or have_dynamic_group
|
||||
)
|
||||
|
||||
@property
|
||||
def manufacturer(self) -> str:
|
||||
"""Return the manufacturer."""
|
||||
if self._manufacturer:
|
||||
return self._manufacturer
|
||||
if not self.model_name:
|
||||
return None
|
||||
return CAST_MANUFACTURERS.get(self.model_name.lower(), "Google Inc.")
|
||||
|
||||
def fill_out_missing_chromecast_info(self) -> "ChromecastInfo":
|
||||
"""Return a new ChromecastInfo object with missing attributes filled in.
|
||||
|
||||
Uses blocking HTTP / HTTPS.
|
||||
"""
|
||||
if self.is_information_complete:
|
||||
# We have all information, no need to check HTTP API.
|
||||
return self
|
||||
|
||||
# Fill out missing group information via HTTP API.
|
||||
if self.is_audio_group:
|
||||
is_dynamic_group = False
|
||||
http_group_status = None
|
||||
if self.uuid:
|
||||
http_group_status = dial.get_multizone_status(
|
||||
self.host,
|
||||
services=self.services,
|
||||
zconf=ChromeCastZeroconf.get_zeroconf(),
|
||||
)
|
||||
if http_group_status is not None:
|
||||
is_dynamic_group = any(
|
||||
str(g.uuid) == self.uuid
|
||||
for g in http_group_status.dynamic_groups
|
||||
)
|
||||
|
||||
return ChromecastInfo(
|
||||
services=self.services,
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
uuid=self.uuid,
|
||||
friendly_name=self.friendly_name,
|
||||
model_name=self.model_name,
|
||||
is_dynamic_group=is_dynamic_group,
|
||||
)
|
||||
|
||||
# Fill out some missing information (friendly_name, uuid) via HTTP dial.
|
||||
http_device_status = dial.get_device_status(
|
||||
self.host, services=self.services, zconf=ChromeCastZeroconf.get_zeroconf()
|
||||
)
|
||||
if http_device_status is None:
|
||||
# HTTP dial didn't give us any new information.
|
||||
return self
|
||||
|
||||
return ChromecastInfo(
|
||||
services=self.services,
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
uuid=(self.uuid or http_device_status.uuid),
|
||||
friendly_name=(self.friendly_name or http_device_status.friendly_name),
|
||||
manufacturer=(self.manufacturer or http_device_status.manufacturer),
|
||||
model_name=(self.model_name or http_device_status.model_name),
|
||||
)
|
||||
|
||||
|
||||
class ChromeCastZeroconf:
|
||||
"""Class to hold a zeroconf instance."""
|
||||
@@ -65,19 +135,22 @@ class CastStatusListener:
|
||||
potentially arrive. This class allows invalidating past chromecast objects.
|
||||
"""
|
||||
|
||||
def __init__(self, cast_device, chromecast, mz_mgr):
|
||||
def __init__(self, cast_device, chromecast, mz_mgr, mz_only=False):
|
||||
"""Initialize the status listener."""
|
||||
self._cast_device = cast_device
|
||||
self._uuid = chromecast.uuid
|
||||
self._valid = True
|
||||
self._mz_mgr = mz_mgr
|
||||
|
||||
if cast_device._cast_info.is_audio_group:
|
||||
self._mz_mgr.add_multizone(chromecast)
|
||||
if mz_only:
|
||||
return
|
||||
|
||||
chromecast.register_status_listener(self)
|
||||
chromecast.socket_client.media_controller.register_status_listener(self)
|
||||
chromecast.register_connection_listener(self)
|
||||
if cast_device._cast_info.is_audio_group:
|
||||
self._mz_mgr.add_multizone(chromecast)
|
||||
else:
|
||||
if not cast_device._cast_info.is_audio_group:
|
||||
self._mz_mgr.register_listener(chromecast.uuid, self)
|
||||
|
||||
def new_cast_status(self, cast_status):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Google Cast",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||
"requirements": ["pychromecast==7.6.0"],
|
||||
"requirements": ["pychromecast==8.0.0"],
|
||||
"after_dependencies": ["cloud", "http", "media_source", "plex", "tts", "zeroconf"],
|
||||
"zeroconf": ["_googlecast._tcp.local."],
|
||||
"codeowners": ["@emontnemery"]
|
||||
|
||||
@@ -63,6 +63,7 @@ from .const import (
|
||||
DOMAIN as CAST_DOMAIN,
|
||||
KNOWN_CHROMECAST_INFO_KEY,
|
||||
SIGNAL_CAST_DISCOVERED,
|
||||
SIGNAL_CAST_REMOVED,
|
||||
SIGNAL_HASS_CAST_SHOW_VIEW,
|
||||
)
|
||||
from .discovery import setup_internal_discovery
|
||||
@@ -115,6 +116,13 @@ def _async_create_cast_device(hass: HomeAssistantType, info: ChromecastInfo):
|
||||
return None
|
||||
# -> New cast device
|
||||
added_casts.add(info.uuid)
|
||||
|
||||
if info.is_dynamic_group:
|
||||
# This is a dynamic group, do not add it but connect to the service.
|
||||
group = DynamicCastGroup(hass, info)
|
||||
group.async_setup()
|
||||
return None
|
||||
|
||||
return CastDevice(info)
|
||||
|
||||
|
||||
@@ -206,8 +214,9 @@ class CastDevice(MediaPlayerEntity):
|
||||
self.hass, SIGNAL_CAST_DISCOVERED, self._async_cast_discovered
|
||||
)
|
||||
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop)
|
||||
self.async_set_cast_info(self._cast_info)
|
||||
self.hass.async_create_task(
|
||||
async_create_catching_coro(self.async_set_cast_info(self._cast_info))
|
||||
async_create_catching_coro(self.async_connect_to_chromecast())
|
||||
)
|
||||
|
||||
self._cast_view_remove_handler = async_dispatcher_connect(
|
||||
@@ -228,15 +237,13 @@ class CastDevice(MediaPlayerEntity):
|
||||
self._cast_view_remove_handler()
|
||||
self._cast_view_remove_handler = None
|
||||
|
||||
async def async_set_cast_info(self, cast_info):
|
||||
"""Set the cast information and set up the chromecast object."""
|
||||
def async_set_cast_info(self, cast_info):
|
||||
"""Set the cast information."""
|
||||
|
||||
self._cast_info = cast_info
|
||||
|
||||
if self._chromecast is not None:
|
||||
# Only setup the chromecast once, added elements to services
|
||||
# will automatically be picked up.
|
||||
return
|
||||
async def async_connect_to_chromecast(self):
|
||||
"""Set up the chromecast object."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"[%s %s] Connecting to cast device by service %s",
|
||||
@@ -248,9 +255,9 @@ class CastDevice(MediaPlayerEntity):
|
||||
pychromecast.get_chromecast_from_service,
|
||||
(
|
||||
self.services,
|
||||
cast_info.uuid,
|
||||
cast_info.model_name,
|
||||
cast_info.friendly_name,
|
||||
self._cast_info.uuid,
|
||||
self._cast_info.model_name,
|
||||
self._cast_info.friendly_name,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
@@ -777,16 +784,12 @@ class CastDevice(MediaPlayerEntity):
|
||||
|
||||
async def _async_cast_discovered(self, discover: ChromecastInfo):
|
||||
"""Handle discovery of new Chromecast."""
|
||||
if self._cast_info.uuid is None:
|
||||
# We can't handle empty UUIDs
|
||||
return
|
||||
|
||||
if self._cast_info.uuid != discover.uuid:
|
||||
# Discovered is not our device.
|
||||
return
|
||||
|
||||
_LOGGER.debug("Discovered chromecast with same UUID: %s", discover)
|
||||
await self.async_set_cast_info(discover)
|
||||
self.async_set_cast_info(discover)
|
||||
|
||||
async def _async_stop(self, event):
|
||||
"""Disconnect socket on Home Assistant stop."""
|
||||
@@ -808,3 +811,131 @@ class CastDevice(MediaPlayerEntity):
|
||||
self._chromecast.register_handler(controller)
|
||||
|
||||
self._hass_cast_controller.show_lovelace_view(view_path, url_path)
|
||||
|
||||
|
||||
class DynamicCastGroup:
|
||||
"""Representation of a Cast device on the network - for dynamic cast groups."""
|
||||
|
||||
def __init__(self, hass, cast_info: ChromecastInfo):
|
||||
"""Initialize the cast device."""
|
||||
|
||||
self.hass = hass
|
||||
self._cast_info = cast_info
|
||||
self.services = cast_info.services
|
||||
self._chromecast: Optional[pychromecast.Chromecast] = None
|
||||
self.mz_mgr = None
|
||||
self._status_listener: Optional[CastStatusListener] = None
|
||||
|
||||
self._add_remove_handler = None
|
||||
self._del_remove_handler = None
|
||||
|
||||
def async_setup(self):
|
||||
"""Create chromecast object."""
|
||||
self._add_remove_handler = async_dispatcher_connect(
|
||||
self.hass, SIGNAL_CAST_DISCOVERED, self._async_cast_discovered
|
||||
)
|
||||
self._del_remove_handler = async_dispatcher_connect(
|
||||
self.hass, SIGNAL_CAST_REMOVED, self._async_cast_removed
|
||||
)
|
||||
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop)
|
||||
self.async_set_cast_info(self._cast_info)
|
||||
self.hass.async_create_task(
|
||||
async_create_catching_coro(self.async_connect_to_chromecast())
|
||||
)
|
||||
|
||||
async def async_tear_down(self) -> None:
|
||||
"""Disconnect Chromecast object."""
|
||||
await self._async_disconnect()
|
||||
if self._cast_info.uuid is not None:
|
||||
# Remove the entity from the added casts so that it can dynamically
|
||||
# be re-added again.
|
||||
self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid)
|
||||
if self._add_remove_handler:
|
||||
self._add_remove_handler()
|
||||
self._add_remove_handler = None
|
||||
if self._del_remove_handler:
|
||||
self._del_remove_handler()
|
||||
self._del_remove_handler = None
|
||||
|
||||
def async_set_cast_info(self, cast_info):
|
||||
"""Set the cast information and set up the chromecast object."""
|
||||
|
||||
self._cast_info = cast_info
|
||||
|
||||
async def async_connect_to_chromecast(self):
|
||||
"""Set the cast information and set up the chromecast object."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"[%s %s] Connecting to cast device by service %s",
|
||||
"Dynamic group",
|
||||
self._cast_info.friendly_name,
|
||||
self.services,
|
||||
)
|
||||
chromecast = await self.hass.async_add_executor_job(
|
||||
pychromecast.get_chromecast_from_service,
|
||||
(
|
||||
self.services,
|
||||
self._cast_info.uuid,
|
||||
self._cast_info.model_name,
|
||||
self._cast_info.friendly_name,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
ChromeCastZeroconf.get_zeroconf(),
|
||||
)
|
||||
self._chromecast = chromecast
|
||||
|
||||
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
|
||||
self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
|
||||
|
||||
self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
|
||||
|
||||
self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr, True)
|
||||
self._chromecast.start()
|
||||
|
||||
async def _async_disconnect(self):
|
||||
"""Disconnect Chromecast object if it is set."""
|
||||
if self._chromecast is None:
|
||||
# Can't disconnect if not connected.
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"[%s %s] Disconnecting from chromecast socket",
|
||||
"Dynamic group",
|
||||
self._cast_info.friendly_name,
|
||||
)
|
||||
|
||||
await self.hass.async_add_executor_job(self._chromecast.disconnect)
|
||||
|
||||
self._invalidate()
|
||||
|
||||
def _invalidate(self):
|
||||
"""Invalidate some attributes."""
|
||||
self._chromecast = None
|
||||
self.mz_mgr = None
|
||||
if self._status_listener is not None:
|
||||
self._status_listener.invalidate()
|
||||
self._status_listener = None
|
||||
|
||||
async def _async_cast_discovered(self, discover: ChromecastInfo):
|
||||
"""Handle discovery of new Chromecast."""
|
||||
if self._cast_info.uuid != discover.uuid:
|
||||
# Discovered is not our device.
|
||||
return
|
||||
|
||||
_LOGGER.debug("Discovered dynamic group with same UUID: %s", discover)
|
||||
self.async_set_cast_info(discover)
|
||||
|
||||
async def _async_cast_removed(self, discover: ChromecastInfo):
|
||||
"""Handle removal of Chromecast."""
|
||||
if self._cast_info.uuid != discover.uuid:
|
||||
# Removed is not our device.
|
||||
return
|
||||
|
||||
if not discover.services:
|
||||
# Clean up the dynamic group
|
||||
_LOGGER.debug("Clean up dynamic group: %s", discover)
|
||||
await self.async_tear_down()
|
||||
|
||||
async def _async_stop(self, event):
|
||||
"""Disconnect socket on Home Assistant stop."""
|
||||
await self._async_disconnect()
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
from hass_nabucasa import cloud_api
|
||||
from hass_nabucasa import Cloud, cloud_api
|
||||
|
||||
from homeassistant.components.alexa import (
|
||||
config as alexa_config,
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.alexa import (
|
||||
state_report as alexa_state_report,
|
||||
)
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, HTTP_BAD_REQUEST
|
||||
from homeassistant.core import callback, split_entity_id
|
||||
from homeassistant.core import HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.util.dt import utcnow
|
||||
@@ -32,10 +32,18 @@ SYNC_DELAY = 1
|
||||
class AlexaConfig(alexa_config.AbstractConfig):
|
||||
"""Alexa Configuration."""
|
||||
|
||||
def __init__(self, hass, config, prefs: CloudPreferences, cloud):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config: dict,
|
||||
cloud_user: str,
|
||||
prefs: CloudPreferences,
|
||||
cloud: Cloud,
|
||||
):
|
||||
"""Initialize the Alexa config."""
|
||||
super().__init__(hass)
|
||||
self._config = config
|
||||
self._cloud_user = cloud_user
|
||||
self._prefs = prefs
|
||||
self._cloud = cloud
|
||||
self._token = None
|
||||
@@ -85,6 +93,11 @@ class AlexaConfig(alexa_config.AbstractConfig):
|
||||
"""Return entity config."""
|
||||
return self._config.get(CONF_ENTITY_CONFIG) or {}
|
||||
|
||||
@callback
|
||||
def user_identifier(self):
|
||||
"""Return an identifier for the user that represents this config."""
|
||||
return self._cloud_user
|
||||
|
||||
def should_expose(self, entity_id):
|
||||
"""If an entity should be exposed."""
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
|
||||
@@ -79,13 +79,15 @@ class CloudClient(Interface):
|
||||
"""Return true if we want start a remote connection."""
|
||||
return self._prefs.remote_enabled
|
||||
|
||||
@property
|
||||
def alexa_config(self) -> alexa_config.AlexaConfig:
|
||||
async def get_alexa_config(self) -> alexa_config.AlexaConfig:
|
||||
"""Return Alexa config."""
|
||||
if self._alexa_config is None:
|
||||
assert self.cloud is not None
|
||||
|
||||
cloud_user = await self._prefs.get_cloud_user()
|
||||
|
||||
self._alexa_config = alexa_config.AlexaConfig(
|
||||
self._hass, self.alexa_user_config, self._prefs, self.cloud
|
||||
self._hass, self.alexa_user_config, cloud_user, self._prefs, self.cloud
|
||||
)
|
||||
|
||||
return self._alexa_config
|
||||
@@ -110,8 +112,9 @@ class CloudClient(Interface):
|
||||
|
||||
async def enable_alexa(_):
|
||||
"""Enable Alexa."""
|
||||
aconf = await self.get_alexa_config()
|
||||
try:
|
||||
await self.alexa_config.async_enable_proactive_mode()
|
||||
await aconf.async_enable_proactive_mode()
|
||||
except aiohttp.ClientError as err: # If no internet available yet
|
||||
if self._hass.is_running:
|
||||
logging.getLogger(__package__).warning(
|
||||
@@ -133,7 +136,7 @@ class CloudClient(Interface):
|
||||
|
||||
tasks = []
|
||||
|
||||
if self.alexa_config.enabled and self.alexa_config.should_report_state:
|
||||
if self._prefs.alexa_enabled and self._prefs.alexa_report_state:
|
||||
tasks.append(enable_alexa)
|
||||
|
||||
if self._prefs.google_enabled:
|
||||
@@ -164,9 +167,10 @@ class CloudClient(Interface):
|
||||
async def async_alexa_message(self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
"""Process cloud alexa message to client."""
|
||||
cloud_user = await self._prefs.get_cloud_user()
|
||||
aconfig = await self.get_alexa_config()
|
||||
return await alexa_sh.async_handle_message(
|
||||
self._hass,
|
||||
self.alexa_config,
|
||||
aconfig,
|
||||
payload,
|
||||
context=Context(user_id=cloud_user),
|
||||
enabled=self._prefs.alexa_enabled,
|
||||
|
||||
@@ -20,6 +20,8 @@ PREF_GOOGLE_LOCAL_WEBHOOK_ID = "google_local_webhook_id"
|
||||
PREF_USERNAME = "username"
|
||||
PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose"
|
||||
PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose"
|
||||
PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
|
||||
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female")
|
||||
DEFAULT_DISABLE_2FA = False
|
||||
DEFAULT_ALEXA_REPORT_STATE = False
|
||||
DEFAULT_GOOGLE_REPORT_STATE = False
|
||||
|
||||
@@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class CloudGoogleConfig(AbstractConfig):
|
||||
"""HA Cloud Configuration for Google Assistant."""
|
||||
|
||||
def __init__(self, hass, config, cloud_user, prefs: CloudPreferences, cloud):
|
||||
def __init__(self, hass, config, cloud_user: str, prefs: CloudPreferences, cloud):
|
||||
"""Initialize the Google config."""
|
||||
super().__init__(hass)
|
||||
self._config = config
|
||||
|
||||
@@ -8,6 +8,7 @@ import async_timeout
|
||||
import attr
|
||||
from hass_nabucasa import Cloud, auth, thingtalk
|
||||
from hass_nabucasa.const import STATE_DISCONNECTED
|
||||
from hass_nabucasa.voice import MAP_VOICE
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
@@ -37,6 +38,7 @@ from .const import (
|
||||
PREF_GOOGLE_DEFAULT_EXPOSE,
|
||||
PREF_GOOGLE_REPORT_STATE,
|
||||
PREF_GOOGLE_SECURE_DEVICES_PIN,
|
||||
PREF_TTS_DEFAULT_VOICE,
|
||||
REQUEST_TIMEOUT,
|
||||
InvalidTrustedNetworks,
|
||||
InvalidTrustedProxies,
|
||||
@@ -115,6 +117,7 @@ async def async_setup(hass):
|
||||
async_register_command(alexa_sync)
|
||||
|
||||
async_register_command(thingtalk_convert)
|
||||
async_register_command(tts_info)
|
||||
|
||||
hass.http.register_view(GoogleActionsSyncView)
|
||||
hass.http.register_view(CloudLoginView)
|
||||
@@ -385,6 +388,9 @@ async def websocket_subscription(hass, connection, msg):
|
||||
vol.Optional(PREF_ALEXA_DEFAULT_EXPOSE): [str],
|
||||
vol.Optional(PREF_GOOGLE_DEFAULT_EXPOSE): [str],
|
||||
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
|
||||
vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All(
|
||||
vol.Coerce(tuple), vol.In(MAP_VOICE)
|
||||
),
|
||||
}
|
||||
)
|
||||
async def websocket_update_prefs(hass, connection, msg):
|
||||
@@ -397,9 +403,10 @@ async def websocket_update_prefs(hass, connection, msg):
|
||||
|
||||
# If we turn alexa linking on, validate that we can fetch access token
|
||||
if changes.get(PREF_ALEXA_REPORT_STATE):
|
||||
alexa_config = await cloud.client.get_alexa_config()
|
||||
try:
|
||||
with async_timeout.timeout(10):
|
||||
await cloud.client.alexa_config.async_get_access_token()
|
||||
await alexa_config.async_get_access_token()
|
||||
except asyncio.TimeoutError:
|
||||
connection.send_error(
|
||||
msg["id"], "alexa_timeout", "Timeout validating Alexa access token."
|
||||
@@ -555,7 +562,8 @@ async def google_assistant_update(hass, connection, msg):
|
||||
async def alexa_list(hass, connection, msg):
|
||||
"""List all alexa entities."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
entities = alexa_entities.async_get_entities(hass, cloud.client.alexa_config)
|
||||
alexa_config = await cloud.client.get_alexa_config()
|
||||
entities = alexa_entities.async_get_entities(hass, alexa_config)
|
||||
|
||||
result = []
|
||||
|
||||
@@ -603,10 +611,11 @@ async def alexa_update(hass, connection, msg):
|
||||
async def alexa_sync(hass, connection, msg):
|
||||
"""Sync with Alexa."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
alexa_config = await cloud.client.get_alexa_config()
|
||||
|
||||
with async_timeout.timeout(10):
|
||||
try:
|
||||
success = await cloud.client.alexa_config.async_sync_entities()
|
||||
success = await alexa_config.async_sync_entities()
|
||||
except alexa_errors.NoTokenAvailable:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
@@ -634,3 +643,11 @@ async def thingtalk_convert(hass, connection, msg):
|
||||
)
|
||||
except thingtalk.ThingTalkConversionError as err:
|
||||
connection.send_error(msg["id"], ws_const.ERR_UNKNOWN_ERROR, str(err))
|
||||
|
||||
|
||||
@websocket_api.websocket_command({"type": "cloud/tts/info"})
|
||||
def tts_info(hass, connection, msg):
|
||||
"""Fetch available tts info."""
|
||||
connection.send_result(
|
||||
msg["id"], {"languages": [(lang, gender.value) for lang, gender in MAP_VOICE]}
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "cloud",
|
||||
"name": "Home Assistant Cloud",
|
||||
"documentation": "https://www.home-assistant.io/integrations/cloud",
|
||||
"requirements": ["hass-nabucasa==0.39.0"],
|
||||
"requirements": ["hass-nabucasa==0.41.0"],
|
||||
"dependencies": ["http", "webhook", "alexa"],
|
||||
"after_dependencies": ["google_assistant"],
|
||||
"codeowners": ["@home-assistant/cloud"]
|
||||
|
||||
@@ -12,6 +12,7 @@ from .const import (
|
||||
DEFAULT_ALEXA_REPORT_STATE,
|
||||
DEFAULT_EXPOSED_DOMAINS,
|
||||
DEFAULT_GOOGLE_REPORT_STATE,
|
||||
DEFAULT_TTS_DEFAULT_VOICE,
|
||||
DOMAIN,
|
||||
PREF_ALEXA_DEFAULT_EXPOSE,
|
||||
PREF_ALEXA_ENTITY_CONFIGS,
|
||||
@@ -30,6 +31,7 @@ from .const import (
|
||||
PREF_GOOGLE_SECURE_DEVICES_PIN,
|
||||
PREF_OVERRIDE_NAME,
|
||||
PREF_SHOULD_EXPOSE,
|
||||
PREF_TTS_DEFAULT_VOICE,
|
||||
PREF_USERNAME,
|
||||
InvalidTrustedNetworks,
|
||||
InvalidTrustedProxies,
|
||||
@@ -86,6 +88,7 @@ class CloudPreferences:
|
||||
google_report_state=UNDEFINED,
|
||||
alexa_default_expose=UNDEFINED,
|
||||
google_default_expose=UNDEFINED,
|
||||
tts_default_voice=UNDEFINED,
|
||||
):
|
||||
"""Update user preferences."""
|
||||
prefs = {**self._prefs}
|
||||
@@ -103,6 +106,7 @@ class CloudPreferences:
|
||||
(PREF_GOOGLE_REPORT_STATE, google_report_state),
|
||||
(PREF_ALEXA_DEFAULT_EXPOSE, alexa_default_expose),
|
||||
(PREF_GOOGLE_DEFAULT_EXPOSE, google_default_expose),
|
||||
(PREF_TTS_DEFAULT_VOICE, tts_default_voice),
|
||||
):
|
||||
if value is not UNDEFINED:
|
||||
prefs[key] = value
|
||||
@@ -203,6 +207,7 @@ class CloudPreferences:
|
||||
PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs,
|
||||
PREF_GOOGLE_REPORT_STATE: self.google_report_state,
|
||||
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
|
||||
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -279,6 +284,11 @@ class CloudPreferences:
|
||||
"""Return the published cloud webhooks."""
|
||||
return self._prefs.get(PREF_CLOUDHOOKS, {})
|
||||
|
||||
@property
|
||||
def tts_default_voice(self):
|
||||
"""Return the default TTS voice."""
|
||||
return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE)
|
||||
|
||||
async def get_cloud_user(self) -> str:
|
||||
"""Return ID from Home Assistant Cloud system user."""
|
||||
user = await self._load_cloud_user()
|
||||
|
||||
@@ -12,13 +12,14 @@ CONF_GENDER = "gender"
|
||||
|
||||
SUPPORT_LANGUAGES = list({key[0] for key in MAP_VOICE})
|
||||
|
||||
DEFAULT_LANG = "en-US"
|
||||
DEFAULT_GENDER = "female"
|
||||
|
||||
|
||||
def validate_lang(value):
|
||||
"""Validate chosen gender or language."""
|
||||
lang = value[CONF_LANG]
|
||||
lang = value.get(CONF_LANG)
|
||||
|
||||
if lang is None:
|
||||
return value
|
||||
|
||||
gender = value.get(CONF_GENDER)
|
||||
|
||||
if gender is None:
|
||||
@@ -35,7 +36,7 @@ def validate_lang(value):
|
||||
PLATFORM_SCHEMA = vol.All(
|
||||
PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_LANG, default=DEFAULT_LANG): str,
|
||||
vol.Optional(CONF_LANG): str,
|
||||
vol.Optional(CONF_GENDER): str,
|
||||
}
|
||||
),
|
||||
@@ -48,8 +49,8 @@ async def async_get_engine(hass, config, discovery_info=None):
|
||||
cloud: Cloud = hass.data[DOMAIN]
|
||||
|
||||
if discovery_info is not None:
|
||||
language = DEFAULT_LANG
|
||||
gender = DEFAULT_GENDER
|
||||
language = None
|
||||
gender = None
|
||||
else:
|
||||
language = config[CONF_LANG]
|
||||
gender = config[CONF_GENDER]
|
||||
@@ -67,6 +68,16 @@ class CloudProvider(Provider):
|
||||
self._language = language
|
||||
self._gender = gender
|
||||
|
||||
if self._language is not None:
|
||||
return
|
||||
|
||||
self._language, self._gender = cloud.client.prefs.tts_default_voice
|
||||
cloud.client.prefs.async_listen_updates(self._sync_prefs)
|
||||
|
||||
async def _sync_prefs(self, prefs):
|
||||
"""Sync preferences."""
|
||||
self._language, self._gender = prefs.tts_default_voice
|
||||
|
||||
@property
|
||||
def default_language(self):
|
||||
"""Return the default language."""
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"domain": "comfoconnect",
|
||||
"name": "Zehnder ComfoAir Q",
|
||||
"documentation": "https://www.home-assistant.io/integrations/comfoconnect",
|
||||
"requirements": ["pycomfoconnect==0.3"],
|
||||
"requirements": ["pycomfoconnect==0.4"],
|
||||
"codeowners": ["@michaelarnauts"]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
|
||||
from pycomfoconnect import (
|
||||
SENSOR_BYPASS_STATE,
|
||||
SENSOR_CURRENT_RMOT,
|
||||
SENSOR_DAYS_TO_REPLACE_FILTER,
|
||||
SENSOR_FAN_EXHAUST_DUTY,
|
||||
SENSOR_FAN_EXHAUST_FLOW,
|
||||
@@ -15,6 +16,9 @@ from pycomfoconnect import (
|
||||
SENSOR_HUMIDITY_OUTDOOR,
|
||||
SENSOR_HUMIDITY_SUPPLY,
|
||||
SENSOR_POWER_CURRENT,
|
||||
SENSOR_POWER_TOTAL,
|
||||
SENSOR_PREHEATER_POWER_CURRENT,
|
||||
SENSOR_PREHEATER_POWER_TOTAL,
|
||||
SENSOR_TEMPERATURE_EXHAUST,
|
||||
SENSOR_TEMPERATURE_EXTRACT,
|
||||
SENSOR_TEMPERATURE_OUTDOOR,
|
||||
@@ -26,9 +30,11 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
CONF_RESOURCES,
|
||||
DEVICE_CLASS_ENERGY,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_POWER,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
PERCENTAGE,
|
||||
POWER_WATT,
|
||||
TEMP_CELSIUS,
|
||||
@@ -45,6 +51,7 @@ ATTR_AIR_FLOW_EXHAUST = "air_flow_exhaust"
|
||||
ATTR_AIR_FLOW_SUPPLY = "air_flow_supply"
|
||||
ATTR_BYPASS_STATE = "bypass_state"
|
||||
ATTR_CURRENT_HUMIDITY = "current_humidity"
|
||||
ATTR_CURRENT_RMOT = "current_rmot"
|
||||
ATTR_CURRENT_TEMPERATURE = "current_temperature"
|
||||
ATTR_DAYS_TO_REPLACE_FILTER = "days_to_replace_filter"
|
||||
ATTR_EXHAUST_FAN_DUTY = "exhaust_fan_duty"
|
||||
@@ -54,6 +61,9 @@ ATTR_EXHAUST_TEMPERATURE = "exhaust_temperature"
|
||||
ATTR_OUTSIDE_HUMIDITY = "outside_humidity"
|
||||
ATTR_OUTSIDE_TEMPERATURE = "outside_temperature"
|
||||
ATTR_POWER_CURRENT = "power_usage"
|
||||
ATTR_POWER_TOTAL = "power_total"
|
||||
ATTR_PREHEATER_POWER_CURRENT = "preheater_power_usage"
|
||||
ATTR_PREHEATER_POWER_TOTAL = "preheater_power_total"
|
||||
ATTR_SUPPLY_FAN_DUTY = "supply_fan_duty"
|
||||
ATTR_SUPPLY_FAN_SPEED = "supply_fan_speed"
|
||||
ATTR_SUPPLY_HUMIDITY = "supply_humidity"
|
||||
@@ -72,7 +82,7 @@ SENSOR_TYPES = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
|
||||
ATTR_LABEL: "Inside Temperature",
|
||||
ATTR_UNIT: TEMP_CELSIUS,
|
||||
ATTR_ICON: "mdi:thermometer",
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_TEMPERATURE_EXTRACT,
|
||||
ATTR_MULTIPLIER: 0.1,
|
||||
},
|
||||
@@ -80,14 +90,22 @@ SENSOR_TYPES = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
|
||||
ATTR_LABEL: "Inside Humidity",
|
||||
ATTR_UNIT: PERCENTAGE,
|
||||
ATTR_ICON: "mdi:water-percent",
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_HUMIDITY_EXTRACT,
|
||||
},
|
||||
ATTR_CURRENT_RMOT: {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
|
||||
ATTR_LABEL: "Current RMOT",
|
||||
ATTR_UNIT: TEMP_CELSIUS,
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_CURRENT_RMOT,
|
||||
ATTR_MULTIPLIER: 0.1,
|
||||
},
|
||||
ATTR_OUTSIDE_TEMPERATURE: {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
|
||||
ATTR_LABEL: "Outside Temperature",
|
||||
ATTR_UNIT: TEMP_CELSIUS,
|
||||
ATTR_ICON: "mdi:thermometer",
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_TEMPERATURE_OUTDOOR,
|
||||
ATTR_MULTIPLIER: 0.1,
|
||||
},
|
||||
@@ -95,14 +113,14 @@ SENSOR_TYPES = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
|
||||
ATTR_LABEL: "Outside Humidity",
|
||||
ATTR_UNIT: PERCENTAGE,
|
||||
ATTR_ICON: "mdi:water-percent",
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_HUMIDITY_OUTDOOR,
|
||||
},
|
||||
ATTR_SUPPLY_TEMPERATURE: {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
|
||||
ATTR_LABEL: "Supply Temperature",
|
||||
ATTR_UNIT: TEMP_CELSIUS,
|
||||
ATTR_ICON: "mdi:thermometer",
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_TEMPERATURE_SUPPLY,
|
||||
ATTR_MULTIPLIER: 0.1,
|
||||
},
|
||||
@@ -110,7 +128,7 @@ SENSOR_TYPES = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
|
||||
ATTR_LABEL: "Supply Humidity",
|
||||
ATTR_UNIT: PERCENTAGE,
|
||||
ATTR_ICON: "mdi:water-percent",
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_HUMIDITY_SUPPLY,
|
||||
},
|
||||
ATTR_SUPPLY_FAN_SPEED: {
|
||||
@@ -145,7 +163,7 @@ SENSOR_TYPES = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
|
||||
ATTR_LABEL: "Exhaust Temperature",
|
||||
ATTR_UNIT: TEMP_CELSIUS,
|
||||
ATTR_ICON: "mdi:thermometer",
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_TEMPERATURE_EXHAUST,
|
||||
ATTR_MULTIPLIER: 0.1,
|
||||
},
|
||||
@@ -153,7 +171,7 @@ SENSOR_TYPES = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
|
||||
ATTR_LABEL: "Exhaust Humidity",
|
||||
ATTR_UNIT: PERCENTAGE,
|
||||
ATTR_ICON: "mdi:water-percent",
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_HUMIDITY_EXHAUST,
|
||||
},
|
||||
ATTR_AIR_FLOW_SUPPLY: {
|
||||
@@ -188,9 +206,30 @@ SENSOR_TYPES = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER,
|
||||
ATTR_LABEL: "Power usage",
|
||||
ATTR_UNIT: POWER_WATT,
|
||||
ATTR_ICON: "mdi:flash",
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_POWER_CURRENT,
|
||||
},
|
||||
ATTR_POWER_TOTAL: {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY,
|
||||
ATTR_LABEL: "Power total",
|
||||
ATTR_UNIT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_POWER_TOTAL,
|
||||
},
|
||||
ATTR_PREHEATER_POWER_CURRENT: {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER,
|
||||
ATTR_LABEL: "Preheater power usage",
|
||||
ATTR_UNIT: POWER_WATT,
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_PREHEATER_POWER_CURRENT,
|
||||
},
|
||||
ATTR_PREHEATER_POWER_TOTAL: {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY,
|
||||
ATTR_LABEL: "Preheater power total",
|
||||
ATTR_UNIT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_ICON: None,
|
||||
ATTR_ID: SENSOR_PREHEATER_POWER_TOTAL,
|
||||
},
|
||||
}
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
|
||||
@@ -318,7 +318,9 @@ async def config_entry_update(hass, connection, msg):
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
@websocket_api.websocket_command({"type": "config_entries/ignore_flow", "flow_id": str})
|
||||
@websocket_api.websocket_command(
|
||||
{"type": "config_entries/ignore_flow", "flow_id": str, "title": str}
|
||||
)
|
||||
async def ignore_config_flow(hass, connection, msg):
|
||||
"""Ignore a config flow."""
|
||||
flow = next(
|
||||
@@ -345,7 +347,7 @@ async def ignore_config_flow(hass, connection, msg):
|
||||
await hass.config_entries.flow.async_init(
|
||||
flow["handler"],
|
||||
context={"source": config_entries.SOURCE_IGNORE},
|
||||
data={"unique_id": flow["context"]["unique_id"]},
|
||||
data={"unique_id": flow["context"]["unique_id"], "title": msg["title"]},
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@@ -102,7 +102,9 @@ async def websocket_update_entity(hass, connection, msg):
|
||||
if hass.states.get(msg["new_entity_id"]) is not None:
|
||||
connection.send_message(
|
||||
websocket_api.error_message(
|
||||
msg["id"], "invalid_info", "Entity is already registered"
|
||||
msg["id"],
|
||||
"invalid_info",
|
||||
"Entity with this ID is already registered",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Daikin AC",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/daikin",
|
||||
"requirements": ["pydaikin==2.4.0"],
|
||||
"requirements": ["pydaikin==2.4.1"],
|
||||
"codeowners": ["@fredrike"],
|
||||
"zeroconf": ["_dkapi._tcp.local."],
|
||||
"quality_scale": "platinum"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "debugpy",
|
||||
"name": "Remote Python Debugger",
|
||||
"documentation": "https://www.home-assistant.io/integrations/debugpy",
|
||||
"requirements": ["debugpy==1.2.0"],
|
||||
"requirements": ["debugpy==1.2.1"],
|
||||
"codeowners": ["@frenck"],
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
||||
@@ -174,6 +174,18 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self.async_create_entry(title=self.bridge_id, data=self.deconz_config)
|
||||
|
||||
async def async_step_reauth(self, config: dict):
|
||||
"""Trigger a reauthentication flow."""
|
||||
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
||||
self.context["title_placeholders"] = {CONF_HOST: config[CONF_HOST]}
|
||||
|
||||
self.deconz_config = {
|
||||
CONF_HOST: config[CONF_HOST],
|
||||
CONF_PORT: config[CONF_PORT],
|
||||
}
|
||||
|
||||
return await self.async_step_link()
|
||||
|
||||
async def async_step_ssdp(self, discovery_info):
|
||||
"""Handle a discovered deCONZ bridge."""
|
||||
if (
|
||||
|
||||
@@ -75,6 +75,7 @@ CONF_SIDE_6 = "side_6"
|
||||
|
||||
HUE_DIMMER_REMOTE_MODEL_GEN1 = "RWL020"
|
||||
HUE_DIMMER_REMOTE_MODEL_GEN2 = "RWL021"
|
||||
HUE_DIMMER_REMOTE_MODEL_GEN3 = "RWL022"
|
||||
HUE_DIMMER_REMOTE = {
|
||||
(CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1000},
|
||||
(CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002},
|
||||
@@ -362,6 +363,7 @@ AQARA_OPPLE_6_BUTTONS = {
|
||||
REMOTES = {
|
||||
HUE_DIMMER_REMOTE_MODEL_GEN1: HUE_DIMMER_REMOTE,
|
||||
HUE_DIMMER_REMOTE_MODEL_GEN2: HUE_DIMMER_REMOTE,
|
||||
HUE_DIMMER_REMOTE_MODEL_GEN3: HUE_DIMMER_REMOTE,
|
||||
HUE_BUTTON_REMOTE_MODEL: HUE_BUTTON_REMOTE,
|
||||
HUE_TAP_REMOTE_MODEL: HUE_TAP_REMOTE,
|
||||
FRIENDS_OF_HUE_SWITCH_MODEL: FRIENDS_OF_HUE_SWITCH,
|
||||
@@ -417,7 +419,15 @@ async def async_validate_trigger_config(hass, config):
|
||||
or device.model not in REMOTES
|
||||
or trigger not in REMOTES[device.model]
|
||||
):
|
||||
raise InvalidDeviceAutomationConfig
|
||||
if not device:
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"deCONZ trigger {trigger} device with id "
|
||||
f"{config[CONF_DEVICE_ID]} not found"
|
||||
)
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"deCONZ trigger {trigger} is not valid for device "
|
||||
f"{device} ({config[CONF_DEVICE_ID]})"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import async_timeout
|
||||
from pydeconz import DeconzSession, errors
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -19,7 +20,7 @@ from .const import (
|
||||
DEFAULT_ALLOW_CLIP_SENSOR,
|
||||
DEFAULT_ALLOW_DECONZ_GROUPS,
|
||||
DEFAULT_ALLOW_NEW_DEVICES,
|
||||
DOMAIN,
|
||||
DOMAIN as DECONZ_DOMAIN,
|
||||
LOGGER,
|
||||
NEW_GROUP,
|
||||
NEW_LIGHT,
|
||||
@@ -34,7 +35,7 @@ from .errors import AuthenticationRequired, CannotConnect
|
||||
@callback
|
||||
def get_gateway_from_config_entry(hass, config_entry):
|
||||
"""Return gateway with a matching bridge id."""
|
||||
return hass.data[DOMAIN][config_entry.unique_id]
|
||||
return hass.data[DECONZ_DOMAIN][config_entry.unique_id]
|
||||
|
||||
|
||||
class DeconzGateway:
|
||||
@@ -152,7 +153,7 @@ class DeconzGateway:
|
||||
# Gateway service
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
identifiers={(DOMAIN, self.api.config.bridgeid)},
|
||||
identifiers={(DECONZ_DOMAIN, self.api.config.bridgeid)},
|
||||
manufacturer="Dresden Elektronik",
|
||||
model=self.api.config.modelid,
|
||||
name=self.api.config.name,
|
||||
@@ -173,8 +174,14 @@ class DeconzGateway:
|
||||
except CannotConnect as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
LOGGER.error("Error connecting with deCONZ gateway: %s", err, exc_info=True)
|
||||
except AuthenticationRequired:
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.flow.async_init(
|
||||
DECONZ_DOMAIN,
|
||||
context={"source": SOURCE_REAUTH},
|
||||
data=self.config_entry.data,
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
for component in SUPPORTED_PLATFORMS:
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Support for deCONZ lights."""
|
||||
|
||||
from pydeconz.light import Light
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP,
|
||||
@@ -105,21 +108,22 @@ class DeconzBaseLight(DeconzDevice, LightEntity):
|
||||
super().__init__(device, gateway)
|
||||
|
||||
self._features = 0
|
||||
self.update_features(self._device)
|
||||
|
||||
if self._device.brightness is not None:
|
||||
def update_features(self, device):
|
||||
"""Calculate supported features of device."""
|
||||
if device.brightness is not None:
|
||||
self._features |= SUPPORT_BRIGHTNESS
|
||||
self._features |= SUPPORT_FLASH
|
||||
self._features |= SUPPORT_TRANSITION
|
||||
|
||||
if self._device.ct is not None:
|
||||
if device.ct is not None:
|
||||
self._features |= SUPPORT_COLOR_TEMP
|
||||
|
||||
if self._device.xy is not None or (
|
||||
self._device.hue is not None and self._device.sat is not None
|
||||
):
|
||||
if device.xy is not None or (device.hue is not None and device.sat is not None):
|
||||
self._features |= SUPPORT_COLOR
|
||||
|
||||
if self._device.effect is not None:
|
||||
if device.effect is not None:
|
||||
self._features |= SUPPORT_EFFECT
|
||||
|
||||
@property
|
||||
@@ -146,7 +150,8 @@ class DeconzBaseLight(DeconzDevice, LightEntity):
|
||||
if self._device.colormode in ("xy", "hs"):
|
||||
if self._device.xy:
|
||||
return color_util.color_xy_to_hs(*self._device.xy)
|
||||
return (self._device.hue / 65535 * 360, self._device.sat / 255 * 100)
|
||||
if self._device.hue and self._device.sat:
|
||||
return (self._device.hue / 65535 * 360, self._device.sat / 255 * 100)
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -250,6 +255,11 @@ class DeconzGroup(DeconzBaseLight):
|
||||
|
||||
super().__init__(device, gateway)
|
||||
|
||||
for light_id in device.lights:
|
||||
light = gateway.api.lights[light_id]
|
||||
if light.ZHATYPE == Light.ZHATYPE:
|
||||
self.update_features(light)
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique identifier for this device."""
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Describe deCONZ logbook events."""
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, CONF_EVENT
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.event import Event
|
||||
|
||||
from .const import DOMAIN as DECONZ_DOMAIN
|
||||
from .deconz_event import CONF_DECONZ_EVENT, DeconzEvent
|
||||
from .device_trigger import (
|
||||
CONF_BOTH_BUTTONS,
|
||||
CONF_BOTTOM_BUTTONS,
|
||||
CONF_BUTTON_1,
|
||||
CONF_BUTTON_2,
|
||||
CONF_BUTTON_3,
|
||||
CONF_BUTTON_4,
|
||||
CONF_CLOSE,
|
||||
CONF_DIM_DOWN,
|
||||
CONF_DIM_UP,
|
||||
CONF_DOUBLE_PRESS,
|
||||
CONF_DOUBLE_TAP,
|
||||
CONF_LEFT,
|
||||
CONF_LONG_PRESS,
|
||||
CONF_LONG_RELEASE,
|
||||
CONF_MOVE,
|
||||
CONF_OPEN,
|
||||
CONF_QUADRUPLE_PRESS,
|
||||
CONF_QUINTUPLE_PRESS,
|
||||
CONF_RIGHT,
|
||||
CONF_ROTATE_FROM_SIDE_1,
|
||||
CONF_ROTATE_FROM_SIDE_2,
|
||||
CONF_ROTATE_FROM_SIDE_3,
|
||||
CONF_ROTATE_FROM_SIDE_4,
|
||||
CONF_ROTATE_FROM_SIDE_5,
|
||||
CONF_ROTATE_FROM_SIDE_6,
|
||||
CONF_ROTATED,
|
||||
CONF_ROTATED_FAST,
|
||||
CONF_ROTATION_STOPPED,
|
||||
CONF_SHAKE,
|
||||
CONF_SHORT_PRESS,
|
||||
CONF_SHORT_RELEASE,
|
||||
CONF_SIDE_1,
|
||||
CONF_SIDE_2,
|
||||
CONF_SIDE_3,
|
||||
CONF_SIDE_4,
|
||||
CONF_SIDE_5,
|
||||
CONF_SIDE_6,
|
||||
CONF_TOP_BUTTONS,
|
||||
CONF_TRIPLE_PRESS,
|
||||
CONF_TURN_OFF,
|
||||
CONF_TURN_ON,
|
||||
REMOTES,
|
||||
_get_deconz_event_from_device_id,
|
||||
)
|
||||
|
||||
ACTIONS = {
|
||||
CONF_SHORT_PRESS: "Short press",
|
||||
CONF_SHORT_RELEASE: "Short release",
|
||||
CONF_LONG_PRESS: "Long press",
|
||||
CONF_LONG_RELEASE: "Long release",
|
||||
CONF_DOUBLE_PRESS: "Double press",
|
||||
CONF_TRIPLE_PRESS: "Triple press",
|
||||
CONF_QUADRUPLE_PRESS: "Quadruple press",
|
||||
CONF_QUINTUPLE_PRESS: "Quintuple press",
|
||||
CONF_ROTATED: "Rotated",
|
||||
CONF_ROTATED_FAST: "Rotated fast",
|
||||
CONF_ROTATION_STOPPED: "Rotated stopped",
|
||||
CONF_MOVE: "Move",
|
||||
CONF_DOUBLE_TAP: "Double tap",
|
||||
CONF_SHAKE: "Shake",
|
||||
CONF_ROTATE_FROM_SIDE_1: "Rotate from side 1",
|
||||
CONF_ROTATE_FROM_SIDE_2: "Rotate from side 2",
|
||||
CONF_ROTATE_FROM_SIDE_3: "Rotate from side 3",
|
||||
CONF_ROTATE_FROM_SIDE_4: "Rotate from side 4",
|
||||
CONF_ROTATE_FROM_SIDE_5: "Rotate from side 5",
|
||||
CONF_ROTATE_FROM_SIDE_6: "Rotate from side 6",
|
||||
}
|
||||
|
||||
INTERFACES = {
|
||||
CONF_TURN_ON: "Turn on",
|
||||
CONF_TURN_OFF: "Turn off",
|
||||
CONF_DIM_UP: "Dim up",
|
||||
CONF_DIM_DOWN: "Dim down",
|
||||
CONF_LEFT: "Left",
|
||||
CONF_RIGHT: "Right",
|
||||
CONF_OPEN: "Open",
|
||||
CONF_CLOSE: "Close",
|
||||
CONF_BOTH_BUTTONS: "Both buttons",
|
||||
CONF_TOP_BUTTONS: "Top buttons",
|
||||
CONF_BOTTOM_BUTTONS: "Bottom buttons",
|
||||
CONF_BUTTON_1: "Button 1",
|
||||
CONF_BUTTON_2: "Button 2",
|
||||
CONF_BUTTON_3: "Button 3",
|
||||
CONF_BUTTON_4: "Button 4",
|
||||
CONF_SIDE_1: "Side 1",
|
||||
CONF_SIDE_2: "Side 2",
|
||||
CONF_SIDE_3: "Side 3",
|
||||
CONF_SIDE_4: "Side 4",
|
||||
CONF_SIDE_5: "Side 5",
|
||||
CONF_SIDE_6: "Side 6",
|
||||
}
|
||||
|
||||
|
||||
def _get_device_event_description(modelid: str, event: str) -> tuple:
|
||||
"""Get device event description."""
|
||||
device_event_descriptions: dict = REMOTES[modelid]
|
||||
|
||||
for event_type_tuple, event_dict in device_event_descriptions.items():
|
||||
if event == event_dict[CONF_EVENT]:
|
||||
return event_type_tuple
|
||||
|
||||
|
||||
@callback
|
||||
def async_describe_events(
|
||||
hass: HomeAssistant,
|
||||
async_describe_event: Callable[[str, str, Callable[[Event], dict]], None],
|
||||
) -> None:
|
||||
"""Describe logbook events."""
|
||||
|
||||
@callback
|
||||
def async_describe_deconz_event(event: Event) -> dict:
|
||||
"""Describe deCONZ logbook event."""
|
||||
deconz_event: Optional[DeconzEvent] = _get_deconz_event_from_device_id(
|
||||
hass, event.data[ATTR_DEVICE_ID]
|
||||
)
|
||||
|
||||
if deconz_event.device.modelid not in REMOTES:
|
||||
return {
|
||||
"name": f"{deconz_event.device.name}",
|
||||
"message": f"fired event '{event.data[CONF_EVENT]}'.",
|
||||
}
|
||||
|
||||
action, interface = _get_device_event_description(
|
||||
deconz_event.device.modelid, event.data[CONF_EVENT]
|
||||
)
|
||||
|
||||
return {
|
||||
"name": f"{deconz_event.device.name}",
|
||||
"message": f"'{ACTIONS[action]}' event for '{INTERFACES[interface]}' was fired.",
|
||||
}
|
||||
|
||||
async_describe_event(DECONZ_DOMAIN, CONF_DECONZ_EVENT, async_describe_deconz_event)
|
||||
@@ -6,6 +6,7 @@
|
||||
"automation",
|
||||
"cloud",
|
||||
"counter",
|
||||
"dhcp",
|
||||
"frontend",
|
||||
"history",
|
||||
"input_boolean",
|
||||
|
||||
@@ -3,7 +3,11 @@ from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
ATTR_TILT_POSITION,
|
||||
SUPPORT_CLOSE,
|
||||
SUPPORT_CLOSE_TILT,
|
||||
SUPPORT_OPEN,
|
||||
SUPPORT_OPEN_TILT,
|
||||
SUPPORT_SET_TILT_POSITION,
|
||||
SUPPORT_STOP_TILT,
|
||||
CoverEntity,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
@@ -26,6 +30,18 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
|
||||
device_class="garage",
|
||||
supported_features=(SUPPORT_OPEN | SUPPORT_CLOSE),
|
||||
),
|
||||
DemoCover(
|
||||
hass,
|
||||
"cover_5",
|
||||
"Pergola Roof",
|
||||
tilt_position=60,
|
||||
supported_features=(
|
||||
SUPPORT_OPEN_TILT
|
||||
| SUPPORT_STOP_TILT
|
||||
| SUPPORT_CLOSE_TILT
|
||||
| SUPPORT_SET_TILT_POSITION
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Denon AVR Network Receivers",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/denonavr",
|
||||
"requirements": ["denonavr==0.9.9", "getmac==0.8.2"],
|
||||
"requirements": ["denonavr==0.9.10", "getmac==0.8.2"],
|
||||
"codeowners": ["@scarface-4711", "@starkillerOG"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.const import (
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
from .const import ATTR_SOURCE_TYPE, DOMAIN, LOGGER
|
||||
from .const import ATTR_HOST_NAME, ATTR_IP, ATTR_MAC, ATTR_SOURCE_TYPE, DOMAIN, LOGGER
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
@@ -130,6 +130,21 @@ class TrackerEntity(BaseTrackerEntity):
|
||||
class ScannerEntity(BaseTrackerEntity):
|
||||
"""Represent a tracked device that is on a scanned network."""
|
||||
|
||||
@property
|
||||
def ip_address(self) -> str:
|
||||
"""Return the primary ip address of the device."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def mac_address(self) -> str:
|
||||
"""Return the mac address of the device."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def hostname(self) -> str:
|
||||
"""Return hostname of the device."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
@@ -141,3 +156,17 @@ class ScannerEntity(BaseTrackerEntity):
|
||||
def is_connected(self):
|
||||
"""Return true if the device is connected to the network."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
"""Return the device state attributes."""
|
||||
attr = {}
|
||||
attr.update(super().state_attributes)
|
||||
if self.ip_address is not None:
|
||||
attr[ATTR_IP] = self.ip_address
|
||||
if self.mac_address is not None:
|
||||
attr[ATTR_MAC] = self.mac_address
|
||||
if self.hostname is not None:
|
||||
attr[ATTR_HOST_NAME] = self.hostname
|
||||
|
||||
return attr
|
||||
|
||||
@@ -34,3 +34,4 @@ ATTR_LOCATION_NAME = "location_name"
|
||||
ATTR_MAC = "mac"
|
||||
ATTR_SOURCE_TYPE = "source_type"
|
||||
ATTR_CONSIDER_HOME = "consider_home"
|
||||
ATTR_IP = "ip"
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""The dhcp integration."""
|
||||
|
||||
from abc import abstractmethod
|
||||
import fnmatch
|
||||
from ipaddress import ip_address as make_ip_address
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from scapy.config import conf
|
||||
from scapy.error import Scapy_Exception
|
||||
from scapy.layers.dhcp import DHCP
|
||||
from scapy.layers.l2 import Ether
|
||||
from scapy.sendrecv import AsyncSniffer
|
||||
|
||||
from homeassistant.components.device_tracker.const import (
|
||||
ATTR_HOST_NAME,
|
||||
ATTR_IP,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
DOMAIN as DEVICE_TRACKER_DOMAIN,
|
||||
SOURCE_TYPE_ROUTER,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
STATE_HOME,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, State, callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.event import async_track_state_added_domain
|
||||
from homeassistant.loader import async_get_dhcp
|
||||
from homeassistant.util.network import is_link_local
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
FILTER = "udp and (port 67 or 68)"
|
||||
REQUESTED_ADDR = "requested_addr"
|
||||
MESSAGE_TYPE = "message-type"
|
||||
HOSTNAME = "hostname"
|
||||
MAC_ADDRESS = "macaddress"
|
||||
IP_ADDRESS = "ip"
|
||||
DHCP_REQUEST = 3
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||
"""Set up the dhcp component."""
|
||||
|
||||
async def _initialize(_):
|
||||
address_data = {}
|
||||
integration_matchers = await async_get_dhcp(hass)
|
||||
watchers = []
|
||||
|
||||
for cls in (DHCPWatcher, DeviceTrackerWatcher):
|
||||
watcher = cls(hass, address_data, integration_matchers)
|
||||
await watcher.async_start()
|
||||
watchers.append(watcher)
|
||||
|
||||
async def _async_stop(*_):
|
||||
for watcher in watchers:
|
||||
await watcher.async_stop()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _initialize)
|
||||
return True
|
||||
|
||||
|
||||
class WatcherBase:
|
||||
"""Base class for dhcp and device tracker watching."""
|
||||
|
||||
def __init__(self, hass, address_data, integration_matchers):
|
||||
"""Initialize class."""
|
||||
super().__init__()
|
||||
|
||||
self.hass = hass
|
||||
self._integration_matchers = integration_matchers
|
||||
self._address_data = address_data
|
||||
|
||||
def process_client(self, ip_address, hostname, mac_address):
|
||||
"""Process a client."""
|
||||
if is_link_local(make_ip_address(ip_address)):
|
||||
# Ignore self assigned addresses
|
||||
return
|
||||
|
||||
data = self._address_data.get(ip_address)
|
||||
|
||||
if data and data[MAC_ADDRESS] == mac_address and data[HOSTNAME] == hostname:
|
||||
# If the address data is the same no need
|
||||
# to process it
|
||||
return
|
||||
|
||||
self._address_data[ip_address] = {MAC_ADDRESS: mac_address, HOSTNAME: hostname}
|
||||
|
||||
self.process_updated_address_data(ip_address, self._address_data[ip_address])
|
||||
|
||||
def process_updated_address_data(self, ip_address, data):
|
||||
"""Process the address data update."""
|
||||
lowercase_hostname = data[HOSTNAME].lower()
|
||||
uppercase_mac = data[MAC_ADDRESS].upper()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Processing updated address data for %s: mac=%s hostname=%s",
|
||||
ip_address,
|
||||
uppercase_mac,
|
||||
lowercase_hostname,
|
||||
)
|
||||
|
||||
for entry in self._integration_matchers:
|
||||
if MAC_ADDRESS in entry and not fnmatch.fnmatch(
|
||||
uppercase_mac, entry[MAC_ADDRESS]
|
||||
):
|
||||
continue
|
||||
|
||||
if HOSTNAME in entry and not fnmatch.fnmatch(
|
||||
lowercase_hostname, entry[HOSTNAME]
|
||||
):
|
||||
continue
|
||||
|
||||
_LOGGER.debug("Matched %s against %s", data, entry)
|
||||
|
||||
self.create_task(
|
||||
self.hass.config_entries.flow.async_init(
|
||||
entry["domain"],
|
||||
context={"source": DOMAIN},
|
||||
data={IP_ADDRESS: ip_address, **data},
|
||||
)
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def create_task(self, task):
|
||||
"""Pass a task to async_add_task based on which context we are in."""
|
||||
|
||||
|
||||
class DeviceTrackerWatcher(WatcherBase):
|
||||
"""Class to watch dhcp data from routers."""
|
||||
|
||||
def __init__(self, hass, address_data, integration_matchers):
|
||||
"""Initialize class."""
|
||||
super().__init__(hass, address_data, integration_matchers)
|
||||
self._unsub = None
|
||||
|
||||
async def async_stop(self):
|
||||
"""Stop watching for new device trackers."""
|
||||
if self._unsub:
|
||||
self._unsub()
|
||||
self._unsub = None
|
||||
|
||||
async def async_start(self):
|
||||
"""Stop watching for new device trackers."""
|
||||
self._unsub = async_track_state_added_domain(
|
||||
self.hass, [DEVICE_TRACKER_DOMAIN], self._async_process_device_event
|
||||
)
|
||||
for state in self.hass.states.async_all(DEVICE_TRACKER_DOMAIN):
|
||||
self._async_process_device_state(state)
|
||||
|
||||
@callback
|
||||
def _async_process_device_event(self, event: Event):
|
||||
"""Process a device tracker state change event."""
|
||||
self._async_process_device_state(event.data.get("new_state"))
|
||||
|
||||
@callback
|
||||
def _async_process_device_state(self, state: State):
|
||||
"""Process a device tracker state."""
|
||||
if state.state != STATE_HOME:
|
||||
return
|
||||
|
||||
attributes = state.attributes
|
||||
|
||||
if attributes.get(ATTR_SOURCE_TYPE) != SOURCE_TYPE_ROUTER:
|
||||
return
|
||||
|
||||
ip_address = attributes.get(ATTR_IP)
|
||||
hostname = attributes.get(ATTR_HOST_NAME)
|
||||
mac_address = attributes.get(ATTR_MAC)
|
||||
|
||||
if ip_address is None or hostname is None or mac_address is None:
|
||||
return
|
||||
|
||||
self.process_client(ip_address, hostname, _format_mac(mac_address))
|
||||
|
||||
def create_task(self, task):
|
||||
"""Pass a task to async_create_task since we are in async context."""
|
||||
self.hass.async_create_task(task)
|
||||
|
||||
|
||||
class DHCPWatcher(WatcherBase):
|
||||
"""Class to watch dhcp requests."""
|
||||
|
||||
def __init__(self, hass, address_data, integration_matchers):
|
||||
"""Initialize class."""
|
||||
super().__init__(hass, address_data, integration_matchers)
|
||||
self._sniffer = None
|
||||
self._started = threading.Event()
|
||||
|
||||
async def async_stop(self):
|
||||
"""Stop watching for new device trackers."""
|
||||
await self.hass.async_add_executor_job(self._stop)
|
||||
|
||||
def _stop(self):
|
||||
"""Stop the thread."""
|
||||
if self._started.is_set():
|
||||
self._sniffer.stop()
|
||||
|
||||
async def async_start(self):
|
||||
"""Start watching for dhcp packets."""
|
||||
try:
|
||||
_verify_l2socket_creation_permission()
|
||||
except (Scapy_Exception, OSError) as ex:
|
||||
if os.geteuid() == 0:
|
||||
_LOGGER.error("Cannot watch for dhcp packets: %s", ex)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Cannot watch for dhcp packets without root or CAP_NET_RAW: %s", ex
|
||||
)
|
||||
return
|
||||
|
||||
self._sniffer = AsyncSniffer(
|
||||
filter=FILTER,
|
||||
started_callback=self._started.set,
|
||||
prn=self.handle_dhcp_packet,
|
||||
store=0,
|
||||
)
|
||||
self._sniffer.start()
|
||||
|
||||
def handle_dhcp_packet(self, packet):
|
||||
"""Process a dhcp packet."""
|
||||
if DHCP not in packet:
|
||||
return
|
||||
|
||||
options = packet[DHCP].options
|
||||
|
||||
request_type = _decode_dhcp_option(options, MESSAGE_TYPE)
|
||||
if request_type != DHCP_REQUEST:
|
||||
# DHCP request
|
||||
return
|
||||
|
||||
ip_address = _decode_dhcp_option(options, REQUESTED_ADDR)
|
||||
hostname = _decode_dhcp_option(options, HOSTNAME)
|
||||
mac_address = _format_mac(packet[Ether].src)
|
||||
|
||||
if ip_address is None or hostname is None or mac_address is None:
|
||||
return
|
||||
|
||||
self.process_client(ip_address, hostname, mac_address)
|
||||
|
||||
def create_task(self, task):
|
||||
"""Pass a task to hass.add_job since we are in a thread."""
|
||||
self.hass.add_job(task)
|
||||
|
||||
|
||||
def _decode_dhcp_option(dhcp_options, key):
|
||||
"""Extract and decode data from a packet option."""
|
||||
for option in dhcp_options:
|
||||
if len(option) < 2 or option[0] != key:
|
||||
continue
|
||||
|
||||
value = option[1]
|
||||
if value is None or key != HOSTNAME:
|
||||
return value
|
||||
|
||||
# hostname is unicode
|
||||
try:
|
||||
return value.decode()
|
||||
except (AttributeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_mac(mac_address):
|
||||
"""Format a mac address for matching."""
|
||||
return format_mac(mac_address).replace(":", "")
|
||||
|
||||
|
||||
def _verify_l2socket_creation_permission():
|
||||
"""Create a socket using the scapy configured l2socket.
|
||||
|
||||
Try to create the socket
|
||||
to see if we have permissions
|
||||
since AsyncSniffer will do it another
|
||||
thread so we will not be able to capture
|
||||
any permission or bind errors.
|
||||
"""
|
||||
conf.L2socket()
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Constants for the dhcp integration."""
|
||||
|
||||
DOMAIN = "dhcp"
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "dhcp",
|
||||
"name": "DHCP Discovery",
|
||||
"documentation": "https://www.home-assistant.io/integrations/dhcp",
|
||||
"requirements": [
|
||||
"scapy==2.4.4"
|
||||
],
|
||||
"codeowners": [
|
||||
"@bdraco"
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
"domain": "discord",
|
||||
"name": "Discord",
|
||||
"documentation": "https://www.home-assistant.io/integrations/discord",
|
||||
"requirements": ["discord.py==1.5.1"],
|
||||
"requirements": ["discord.py==1.6.0"],
|
||||
"codeowners": []
|
||||
}
|
||||
|
||||
@@ -16,10 +16,15 @@ import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_TOKEN): cv.string})
|
||||
|
||||
ATTR_EMBED = "embed"
|
||||
ATTR_EMBED_AUTHOR = "author"
|
||||
ATTR_EMBED_FIELDS = "fields"
|
||||
ATTR_EMBED_FOOTER = "footer"
|
||||
ATTR_EMBED_THUMBNAIL = "thumbnail"
|
||||
ATTR_IMAGES = "images"
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_TOKEN): cv.string})
|
||||
|
||||
|
||||
def get_service(hass, config, discovery_info=None):
|
||||
"""Get the Discord notification service."""
|
||||
@@ -43,16 +48,21 @@ class DiscordNotificationService(BaseNotificationService):
|
||||
|
||||
async def async_send_message(self, message, **kwargs):
|
||||
"""Login to Discord, send message to channel(s) and log out."""
|
||||
|
||||
discord.VoiceClient.warn_nacl = False
|
||||
discord_bot = discord.Client()
|
||||
images = None
|
||||
embedding = None
|
||||
|
||||
if ATTR_TARGET not in kwargs:
|
||||
_LOGGER.error("No target specified")
|
||||
return None
|
||||
|
||||
data = kwargs.get(ATTR_DATA) or {}
|
||||
|
||||
if ATTR_EMBED in data:
|
||||
embedding = data[ATTR_EMBED]
|
||||
fields = embedding.get(ATTR_EMBED_FIELDS)
|
||||
|
||||
if ATTR_IMAGES in data:
|
||||
images = []
|
||||
|
||||
@@ -86,7 +96,20 @@ class DiscordNotificationService(BaseNotificationService):
|
||||
files = []
|
||||
for image in images:
|
||||
files.append(discord.File(image))
|
||||
await channel.send(message, files=files)
|
||||
if embedding:
|
||||
embed = discord.Embed(**embedding)
|
||||
if fields:
|
||||
for field_num, field_name in enumerate(fields):
|
||||
embed.add_field(**fields[field_num])
|
||||
if ATTR_EMBED_FOOTER in embedding:
|
||||
embed.set_footer(**embedding[ATTR_EMBED_FOOTER])
|
||||
if ATTR_EMBED_AUTHOR in embedding:
|
||||
embed.set_author(**embedding[ATTR_EMBED_AUTHOR])
|
||||
if ATTR_EMBED_THUMBNAIL in embedding:
|
||||
embed.set_thumbnail(**embedding[ATTR_EMBED_THUMBNAIL])
|
||||
await channel.send(message, files=files, embed=embed)
|
||||
else:
|
||||
await channel.send(message, files=files)
|
||||
except (discord.errors.HTTPException, discord.errors.NotFound) as error:
|
||||
_LOGGER.warning("Communication error: %s", error)
|
||||
await discord_bot.logout()
|
||||
|
||||
@@ -281,7 +281,9 @@ class DlnaDmrDevice(MediaPlayerEntity):
|
||||
@property
|
||||
def volume_level(self):
|
||||
"""Volume level of the media player (0..1)."""
|
||||
return self._device.volume_level
|
||||
if self._device.has_volume_level:
|
||||
return self._device.volume_level
|
||||
return 0
|
||||
|
||||
@catch_request_errors()
|
||||
async def async_set_volume_level(self, volume):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user