Compare commits

..

134 Commits

Author SHA1 Message Date
Paulus Schoutsen 898af3e04c Merge pull request #68001 from home-assistant/rc 2022-03-11 17:11:03 -08:00
Diogo Gomes 3de341099f Bump pymediaroom (#68016) 2022-03-11 15:45:40 -08:00
Paulus Schoutsen 7fb76c68bb Bumped version to 2022.3.4 2022-03-11 09:25:55 -08:00
Guido Schmitz 7de5e070fb Bump pysabnzbd to 1.1.1 (#67971) 2022-03-11 09:24:50 -08:00
Tom Harris 1bfb01e0d1 Rollback pyinsteon (#67956) 2022-03-11 09:24:50 -08:00
Erik Montnemery ca664ab5a5 Correct local import of paho-mqtt (#67944)
* Correct local import of paho-mqtt

* Remove MqttClientSetup.mqtt class attribute

* Remove reference to MqttClientSetup.mqtt
2022-03-11 09:24:49 -08:00
Franck Nijhof 5a39e63d25 Update radios to 0.1.1 (#67902) 2022-03-11 09:24:48 -08:00
Joakim Plate c608cafebd Make sure blueprint cache is flushed on script reload (#67899) 2022-03-11 09:24:47 -08:00
Shay Levy 07e70c81b0 Fix shelly duo scene restore (#67871) 2022-03-11 09:24:46 -08:00
J. Nick Koston cad397d6a7 Add missing callback decorator to sun (#67840) 2022-03-11 09:24:45 -08:00
Raman Gupta c22af2c82a Bump zwave-js-server-python to 0.35.2 (#67839) 2022-03-11 09:24:45 -08:00
Richard de Boer f5b6d93706 Support playing local "file" media on Kodi (#67832)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2022-03-11 09:24:44 -08:00
cheng2wei 28b3edf6b2 Fix discord embed class initialization (#67831) 2022-03-11 09:24:43 -08:00
Paulus Schoutsen 737c502e94 Merge pull request #67838 from home-assistant/rc 2022-03-07 21:51:30 -08:00
Paulus Schoutsen a1abcbc7eb Bumped version to 2022.3.3 2022-03-07 20:45:49 -08:00
J. Nick Koston b09ab2dafb Prevent scene from restoring unavailable states (#67836) 2022-03-07 20:45:44 -08:00
Teemu R 4e6fc3615b Bump python-miio version to 0.5.11 (#67824) 2022-03-07 20:45:43 -08:00
Bram Kragten 580c998552 Update frontend to 20220301.1 (#67812) 2022-03-07 20:45:25 -08:00
Franck Nijhof 97ba17d1ec Catch Elgato connection errors (#67799) 2022-03-07 20:44:09 -08:00
J. Nick Koston 8d7cdceb75 Handle fan_modes being set to None in homekit (#67790) 2022-03-07 20:44:08 -08:00
Simone Chemelli dfa1c3abb3 Fix profile name update for Shelly Valve (#67778) 2022-03-07 20:44:08 -08:00
Simone Chemelli c807c57a9b Fix internet access switch for old discovery (#67777) 2022-03-07 20:44:07 -08:00
J. Nick Koston f4ec7e0902 Prevent polling from recreating an entity after removal (#67750) 2022-03-07 20:44:06 -08:00
G Johansson 814c96834e Fix temperature stepping in Sensibo (#67737)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2022-03-07 20:44:05 -08:00
muppet3000 87492e6b3e Fix timezone for growatt lastdataupdate (#67684)
* Added timezone for growatt lastdataupdate (#67646)

* Growatt lastdataupdate set to local timezone
2022-03-07 20:44:05 -08:00
Jan Bouwhuis 4aaafb0a99 Fix false positive MQTT climate deprecation warnings for defaults (#67661)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2022-03-07 20:44:04 -08:00
Paulus Schoutsen 2aecdd3d6d Merge pull request #67730 from home-assistant/rc 2022-03-06 12:11:16 -08:00
Jc2k 76336df91a Fix regression with homekit_controller + Aqara motion/vibration sensors (#67740) 2022-03-06 08:45:56 -08:00
Paulus Schoutsen 88e0380aa2 Bumped version to 2022.3.2 2022-03-06 00:07:45 -08:00
Avi Miller 10a2c97cab Update aiolifx dependency to resolve log flood (#67721) 2022-03-06 00:07:41 -08:00
J. Nick Koston 92c3c08a10 Add missing disconnect in elkm1 config flow validation (#67716) 2022-03-06 00:07:40 -08:00
J. Nick Koston 4f8b69d985 Ensure elkm1 can be manually configured when discovered instance is not used (#67712) 2022-03-06 00:07:39 -08:00
Martin Hjelmare f5aaf44e50 Bump pydroid-ipcam to 1.3.1 (#67655)
* Bump pydroid-ipcam to 1.3.1

* Remove loop and set ssl to False
2022-03-06 00:07:39 -08:00
Erik Montnemery f3c85b3459 Fix reload of media player groups (#67653) 2022-03-06 00:07:38 -08:00
Franck Nijhof d7348718e0 Fix Fan template loosing percentage/preset (#67648)
Co-authored-by: J. Nick Koston <nick@koston.org>
2022-03-06 00:07:37 -08:00
Simone Chemelli 2a6d5ea7bd Improve logging for Fritz switches creation (#67640) 2022-03-06 00:07:37 -08:00
Simone Chemelli 5ae83e3c40 Allign logic for Fritz sensors and binary_sensors (#67623) 2022-03-06 00:07:36 -08:00
G Johansson 5657a9e6bd Fix sql false warning (#67614) 2022-03-06 00:07:35 -08:00
J. Nick Koston b290e62170 Handle elkm1 login case with username and insecure login (#67602) 2022-03-06 00:07:35 -08:00
epenet 679ddbd1be Downgrade Renault warning (#67601)
Co-authored-by: epenet <epenet@users.noreply.github.com>
2022-03-06 00:07:34 -08:00
Teemu R b54652a849 Remove use of deprecated xiaomi_miio classes (#67590) 2022-03-06 00:07:33 -08:00
Joakim Plate 24013ad94c rfxtrx: bump to 0.28 (#67530) 2022-03-06 00:07:32 -08:00
Chris Talkington 9849b86a84 Suppress roku power off timeout errors (#67414) 2022-03-06 00:07:32 -08:00
Simone Chemelli 8bbf55c85d Add unique_id to Fritz diagnostics (#67384)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-03-06 00:07:31 -08:00
Paulus Schoutsen 0541c708da Merge pull request #67588 from home-assistant/rc 2022-03-03 18:49:44 -08:00
Paulus Schoutsen ba40d62081 Bumped version to 2022.3.1 2022-03-03 15:53:54 -08:00
J. Nick Koston 73765a1f29 Add guards for HomeKit version/names that break apple watches (#67585) 2022-03-03 15:53:46 -08:00
muppet3000 b5b945ab4d Fix data type for growatt lastdataupdate (#67511) (#67582)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2022-03-03 15:53:46 -08:00
Emory Penney d361643500 Bump pyobihai (#67571) 2022-03-03 15:53:45 -08:00
Paulus Schoutsen eff7a12557 Highlight in logs it is a custom component when setup fails (#67559)
Co-authored-by: Joakim Sørensen <ludeeus@ludeeus.dev>
2022-03-03 15:53:44 -08:00
Jan Bouwhuis 63f8e9ee08 Fix MQTT config flow with advanced parameters (#67556)
* Fix MQTT config flow with advanced parameters

* Add test
2022-03-03 15:53:44 -08:00
Simone Chemelli ee0bdaa2de Check if UPnP is enabled on Fritz device (#67512)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-03-03 15:48:24 -08:00
jjlawren 48d9e9a83c Bump soco to 0.26.4 (#67498) 2022-03-03 15:47:50 -08:00
Franck Nijhof 8de94f3b5c Merge pull request #67487 from home-assistant/rc 2022-03-02 19:56:49 +01:00
Joakim Sørensen d7c480f2d8 Set fail-fast to false for meta container (#67484) 2022-03-02 19:00:52 +01:00
Joakim Sørensen 0349d7d09d Split meta image creation (#67480) 2022-03-02 19:00:49 +01:00
dependabot[bot] be19a2e2ab Bump docker/login-action from 1.14.0 to 1.14.1 (#67462)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-02 19:00:46 +01:00
dependabot[bot] b9f44eec0a Bump docker/login-action from 1.13.0 to 1.14.0 (#67416)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-02 19:00:42 +01:00
Joakim Plate 9db56a8119 Don't trigger device removal for non rfxtrx devices (#67315) 2022-03-02 18:44:43 +01:00
Franck Nijhof ddf7efd937 Bumped version to 2022.3.0 2022-03-02 18:18:58 +01:00
Paulus Schoutsen da4f4f641d Add guard radio browser media source (#67486) 2022-03-02 18:18:28 +01:00
cnico 288270ac08 Address late review of flipr (#67477) 2022-03-02 18:18:25 +01:00
Shay Levy 092b973067 Bump aioshelly to 1.0.11 (#67476) 2022-03-02 18:18:22 +01:00
Michael Chisholm 9aba0ba990 Sort DMS results using only criteria supported by the device (#67475) 2022-03-02 18:18:18 +01:00
Jc2k 4668720f02 Remove Ecobee homekit vendor extensions that just don't work (#67474) 2022-03-02 18:18:14 +01:00
Jc2k 274e4d5558 Bump to aiohomekit 0.7.15 (#67470) 2022-03-02 18:18:11 +01:00
Erik Montnemery 94fd7ec028 Improve binary sensor group when member is unknown or unavailable (#67468) 2022-03-02 18:18:05 +01:00
Joakim Plate c81ccaebd3 Rfxtrx correct overzealous type checking (#67437) 2022-03-02 18:18:02 +01:00
Erik Montnemery 4c0ba7cd77 Improve mobile_app key handling (#67429) 2022-03-02 18:17:58 +01:00
Paulus Schoutsen 1ebb4cf395 Bumped version to 2022.3.0b6 2022-03-01 17:21:51 -08:00
Simone Chemelli 17bc8c64f8 Add missing temperature sensor for Shelly Motion2 (#67458) 2022-03-01 16:57:52 -08:00
Paulus Schoutsen fa01715bbb Bump frontend to 20220301.0 (#67457) 2022-03-01 16:57:27 -08:00
Franck Nijhof 99322e2658 Fix CO2Signal having unknown data (#67453) 2022-03-01 16:56:45 -08:00
Teemu R 9a306e2a89 Bump python-songpal to 0.14.1 (#67435)
Changelog https://github.com/rytilahti/python-songpal/releases/tag/0.14.1
2022-03-01 16:56:44 -08:00
JeroenTuinstra 47812c6b91 Correct selector for remote integration line 50 (#67432) 2022-03-01 16:56:43 -08:00
jan iversen 40d72b3188 CONF_SLAVE do not have default 0 in a validator (#67418) 2022-03-01 16:56:42 -08:00
J. Nick Koston b31e570ec7 Avoid creating wiring select for Magic Home if its not supported (#67417) 2022-03-01 16:56:41 -08:00
Paulus Schoutsen 768a031128 Restore children media class (#67409) 2022-03-01 16:56:41 -08:00
Paulus Schoutsen f1620cbb2e Add support for detecting hostname based addresses as internal (#67407) 2022-03-01 16:56:40 -08:00
cnico aeac31c926 Add flipr API error detection and catch it correctly. (#67405) 2022-03-01 16:56:39 -08:00
Jeff 26203e9924 Support disconnected Powerwall configuration (#67325)
Co-authored-by: J. Nick Koston <nick@koston.org>
2022-03-01 16:56:39 -08:00
J. Nick Koston d766b17323 Partially revert powerwall abs change from #67245 (#67300) 2022-03-01 16:56:37 -08:00
Paulus Schoutsen ee3be011a5 Bumped version to 2022.3.0b5 2022-02-28 17:02:34 -08:00
J. Nick Koston cd5056fdab Bump zeroconf to 0.38.4 (#67406) 2022-02-28 17:02:27 -08:00
jjlawren 4423ecbe1c Reduce magic in Sonos error handling fixture (#67401) 2022-02-28 17:02:27 -08:00
Erik Montnemery 06791d42f2 Fix race when unsubscribing from MQTT topics (#67376)
* Fix race when unsubscribing from MQTT topics

* Improve test
2022-02-28 17:02:26 -08:00
jjlawren e4c8ac64a4 Bump plexapi to 4.10.0 (#67364) 2022-02-28 17:02:25 -08:00
Paulus Schoutsen aee2a8bc51 Guard for non-string inputs in Alexa (#67348) 2022-02-28 17:02:24 -08:00
Paulus Schoutsen 6d5be01677 Guard for index error in picnic (#67345) 2022-02-28 17:02:24 -08:00
Mick Vleeshouwer 2639965b24 Bump pyoverkiz to 1.3.9 in Overkiz integration (#67339) 2022-02-28 17:02:23 -08:00
Marc Mueller b468cc8c9e Remove redundant type cast (#67317) 2022-02-28 17:02:22 -08:00
Paulus Schoutsen 8c3c8ff1d4 Bumped version to 2022.3.0b4 2022-02-26 14:13:19 -08:00
Paulus Schoutsen 23846eb120 Bump frontend to 20220226.0 (#67313) 2022-02-26 14:13:12 -08:00
Martin Hjelmare 61b4386053 Fix dhcp None hostname (#67289)
* Fix dhcp None hostname

* Test handle None hostname
2022-02-26 14:13:12 -08:00
pailloM a3cdc2facb Re-enable apcupsd (#67264) 2022-02-26 14:13:11 -08:00
Alan Tse 5cffec8b23 Fix Doorbird warning if registering favorites fail (#67262) 2022-02-26 14:13:10 -08:00
Paulus Schoutsen 5b5aa3d604 Kodi: Mark MJPEG cameras using PNGs as incompatible (#67257) 2022-02-26 14:13:10 -08:00
Paulus Schoutsen f21ee7a748 Fix camera content type while browsing (#67256) 2022-02-26 14:13:09 -08:00
Paulus Schoutsen 86f511ac6a Bump hass-nabucasa to 0.54.0 (#67252) 2022-02-26 14:13:08 -08:00
Paulus Schoutsen 241611ff05 Kodi/Roku: Add brand logos to brand folders at root level (#67251) 2022-02-26 14:13:08 -08:00
J. Nick Koston d16f0ba32b Prevent the wrong WiZ device from being used when the IP is a different device (#67250) 2022-02-26 14:13:07 -08:00
Paulus Schoutsen f39aea70e6 Give Sonos media browse folders Sonos logos to distinguish from media… (#67248) 2022-02-26 14:13:06 -08:00
Paulus Schoutsen 2d53e222ff Improve not shown handling (#67247) 2022-02-26 14:13:05 -08:00
J. Nick Koston fb82013c39 Fix powerwall data incompatibility with energy integration (#67245) 2022-02-26 14:13:05 -08:00
stegm 33969fd4c1 Add diagnostics to Kostal Plenticore (#66435) 2022-02-26 14:13:04 -08:00
Paulus Schoutsen a7c67e6cde Bumped version to 2022.3.0b3 2022-02-25 10:01:16 -08:00
Paulus Schoutsen b767f83dc6 Adjust serializing resolved media (#67240) 2022-02-25 10:01:06 -08:00
Martin Hjelmare b3db4133c8 Fix zwave_js migration luminance sensor (#67234) 2022-02-25 10:01:05 -08:00
Franck Nijhof d9195434de Move Phone Modem reject call deprecation warning (#67223) 2022-02-25 10:01:04 -08:00
Franck Nijhof 2c075a00c7 Add support for 8-gang switches to Tuya (#67218) 2022-02-25 10:01:03 -08:00
Raman Gupta 549756218b Don't add extra entities for zwave_js controller (#67209)
* Don't add extra entities for zwave_js controller

* Revert reformat of controller_state

* fix indentation issues

* fix indentation issues
2022-02-25 10:01:02 -08:00
Avi Miller 921a011391 Bump the Twinkly dependency to fix the excessive debug output (#67207) 2022-02-25 10:01:02 -08:00
Paulus Schoutsen 51771707fb Add media source support to Kodi (#67203) 2022-02-25 10:01:01 -08:00
jjlawren 73eff0dde4 Adjust Sonos visibility checks (#67196) 2022-02-25 10:01:00 -08:00
martijnvanduijneveldt 53632cc9e5 Fix nanoleaf white flashing when using scenes (#67168) 2022-02-25 10:00:59 -08:00
Mark Dietzer b572d10e42 Fix Twitch component to use new API (#67153)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2022-02-25 10:00:58 -08:00
kevdliu 6fcdd3b411 Take Abode camera snapshot before fetching latest image (#67150)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2022-02-25 10:00:57 -08:00
Paulus Schoutsen 3372bdfc0f Bumped version to 2022.3.0b2 2022-02-24 20:57:23 -08:00
Zack Barett 18087caf85 20220224.0 (#67204) 2022-02-24 20:57:16 -08:00
Paulus Schoutsen 9e7cbb011c Bump aiohue to 4.3.0 (#67202) 2022-02-24 20:57:15 -08:00
Paulus Schoutsen 694fb2ddde Move media_source to after_deps (#67195) 2022-02-24 20:57:15 -08:00
J. Nick Koston 32566085c8 Fix ElkM1 systems that do not use password authentication (#67194) 2022-02-24 20:57:14 -08:00
J. Nick Koston 3c0cd126dd Move camera to after deps for HomeKit (#67190) 2022-02-24 20:57:13 -08:00
Franck Nijhof 596f3110ba Fix MQTT config entry deprecation warnings (#67174) 2022-02-24 20:57:13 -08:00
Paulus Schoutsen 37ebeae83b Bumped version to 2022.3.0b1 2022-02-23 22:16:27 -08:00
Keilin Bickar 70f9196e8f SleepIQ Dependency update (#67154) 2022-02-23 22:16:16 -08:00
Gage Benne 25933e1186 Bump pydexcom to 0.2.3 (#67152) 2022-02-23 22:16:16 -08:00
Robert Hillis f40f25473c Bump aiopyarr to 22.2.2 (#67149) 2022-02-23 22:16:15 -08:00
J. Nick Koston f0383782f9 Use compact encoding for JSON websocket messages (#67148)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-02-23 22:16:14 -08:00
Paulus Schoutsen 0cd4f74d73 Allow get_states to recover (#67146) 2022-02-23 22:16:13 -08:00
Paulus Schoutsen 6a31cd9279 Fix SQL sensor (#67144) 2022-02-23 22:16:12 -08:00
jjlawren 3550a92629 Fix Sonos radio metadata processing with missing data (#67141) 2022-02-23 22:16:11 -08:00
Paulus Schoutsen b0d043c55b Media source to verify domain to avoid KeyError (#67137) 2022-02-23 22:16:11 -08:00
soluga b21d954e50 Don't try to resolve state if native_value is Null (#67134) 2022-02-23 22:16:10 -08:00
218 changed files with 3110 additions and 966 deletions
+61 -56
View File
@@ -122,13 +122,13 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to DockerHub
uses: docker/login-action@v1.13.0
uses: docker/login-action@v1.14.1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v1.13.0
uses: docker/login-action@v1.14.1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -187,13 +187,13 @@ jobs:
fi
- name: Login to DockerHub
uses: docker/login-action@v1.13.0
uses: docker/login-action@v1.14.1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v1.13.0
uses: docker/login-action@v1.14.1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -243,22 +243,30 @@ jobs:
channel: beta
publish_container:
name: Publish meta container
name: Publish meta container for ${{ matrix.registry }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
registry:
- "ghcr.io/home-assistant"
- "homeassistant"
steps:
- name: Checkout the repository
uses: actions/checkout@v2.4.0
- name: Login to DockerHub
uses: docker/login-action@v1.13.0
if: matrix.registry == 'homeassistant'
uses: docker/login-action@v1.14.1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v1.13.0
if: matrix.registry == 'ghcr.io/home-assistant'
uses: docker/login-action@v1.14.1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -273,38 +281,37 @@ jobs:
export DOCKER_CLI_EXPERIMENTAL=enabled
function create_manifest() {
local docker_reg=${1}
local tag_l=${2}
local tag_r=${3}
local tag_l=${1}
local tag_r=${2}
docker manifest create "${docker_reg}/home-assistant:${tag_l}" \
"${docker_reg}/amd64-homeassistant:${tag_r}" \
"${docker_reg}/i386-homeassistant:${tag_r}" \
"${docker_reg}/armhf-homeassistant:${tag_r}" \
"${docker_reg}/armv7-homeassistant:${tag_r}" \
"${docker_reg}/aarch64-homeassistant:${tag_r}"
docker manifest create "${{ matrix.registry }}/home-assistant:${tag_l}" \
"${{ matrix.registry }}/amd64-homeassistant:${tag_r}" \
"${{ matrix.registry }}/i386-homeassistant:${tag_r}" \
"${{ matrix.registry }}/armhf-homeassistant:${tag_r}" \
"${{ matrix.registry }}/armv7-homeassistant:${tag_r}" \
"${{ matrix.registry }}/aarch64-homeassistant:${tag_r}"
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
"${docker_reg}/amd64-homeassistant:${tag_r}" \
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
"${{ matrix.registry }}/amd64-homeassistant:${tag_r}" \
--os linux --arch amd64
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
"${docker_reg}/i386-homeassistant:${tag_r}" \
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
"${{ matrix.registry }}/i386-homeassistant:${tag_r}" \
--os linux --arch 386
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
"${docker_reg}/armhf-homeassistant:${tag_r}" \
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
"${{ matrix.registry }}/armhf-homeassistant:${tag_r}" \
--os linux --arch arm --variant=v6
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
"${docker_reg}/armv7-homeassistant:${tag_r}" \
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
"${{ matrix.registry }}/armv7-homeassistant:${tag_r}" \
--os linux --arch arm --variant=v7
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
"${docker_reg}/aarch64-homeassistant:${tag_r}" \
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
"${{ matrix.registry }}/aarch64-homeassistant:${tag_r}" \
--os linux --arch arm64 --variant=v8
docker manifest push --purge "${docker_reg}/home-assistant:${tag_l}"
docker manifest push --purge "${{ matrix.registry }}/home-assistant:${tag_l}"
}
function validate_image() {
@@ -315,36 +322,34 @@ jobs:
fi
}
for docker_reg in "homeassistant" "ghcr.io/home-assistant"; do
docker pull "${docker_reg}/amd64-homeassistant:${{ needs.init.outputs.version }}"
docker pull "${docker_reg}/i386-homeassistant:${{ needs.init.outputs.version }}"
docker pull "${docker_reg}/armhf-homeassistant:${{ needs.init.outputs.version }}"
docker pull "${docker_reg}/armv7-homeassistant:${{ needs.init.outputs.version }}"
docker pull "${docker_reg}/aarch64-homeassistant:${{ needs.init.outputs.version }}"
docker pull "${{ matrix.registry }}/amd64-homeassistant:${{ needs.init.outputs.version }}"
docker pull "${{ matrix.registry }}/i386-homeassistant:${{ needs.init.outputs.version }}"
docker pull "${{ matrix.registry }}/armhf-homeassistant:${{ needs.init.outputs.version }}"
docker pull "${{ matrix.registry }}/armv7-homeassistant:${{ needs.init.outputs.version }}"
docker pull "${{ matrix.registry }}/aarch64-homeassistant:${{ needs.init.outputs.version }}"
validate_image "${docker_reg}/amd64-homeassistant:${{ needs.init.outputs.version }}"
validate_image "${docker_reg}/i386-homeassistant:${{ needs.init.outputs.version }}"
validate_image "${docker_reg}/armhf-homeassistant:${{ needs.init.outputs.version }}"
validate_image "${docker_reg}/armv7-homeassistant:${{ needs.init.outputs.version }}"
validate_image "${docker_reg}/aarch64-homeassistant:${{ needs.init.outputs.version }}"
validate_image "${{ matrix.registry }}/amd64-homeassistant:${{ needs.init.outputs.version }}"
validate_image "${{ matrix.registry }}/i386-homeassistant:${{ needs.init.outputs.version }}"
validate_image "${{ matrix.registry }}/armhf-homeassistant:${{ needs.init.outputs.version }}"
validate_image "${{ matrix.registry }}/armv7-homeassistant:${{ needs.init.outputs.version }}"
validate_image "${{ matrix.registry }}/aarch64-homeassistant:${{ needs.init.outputs.version }}"
# Create version tag
create_manifest "${docker_reg}" "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}"
# Create version tag
create_manifest "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}"
# Create general tags
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
create_manifest "${docker_reg}" "dev" "${{ needs.init.outputs.version }}"
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
create_manifest "${docker_reg}" "beta" "${{ needs.init.outputs.version }}"
create_manifest "${docker_reg}" "rc" "${{ needs.init.outputs.version }}"
else
create_manifest "${docker_reg}" "stable" "${{ needs.init.outputs.version }}"
create_manifest "${docker_reg}" "latest" "${{ needs.init.outputs.version }}"
create_manifest "${docker_reg}" "beta" "${{ needs.init.outputs.version }}"
create_manifest "${docker_reg}" "rc" "${{ needs.init.outputs.version }}"
# Create general tags
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
create_manifest"dev" "${{ needs.init.outputs.version }}"
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
create_manifest "beta" "${{ needs.init.outputs.version }}"
create_manifest "rc" "${{ needs.init.outputs.version }}"
else
create_manifest "stable" "${{ needs.init.outputs.version }}"
create_manifest "latest" "${{ needs.init.outputs.version }}"
create_manifest "beta" "${{ needs.init.outputs.version }}"
create_manifest "rc" "${{ needs.init.outputs.version }}"
# Create series version tag (e.g. 2021.6)
v="${{ needs.init.outputs.version }}"
create_manifest "${docker_reg}" "${v%.*}" "${{ needs.init.outputs.version }}"
fi
done
# Create series version tag (e.g. 2021.6)
v="${{ needs.init.outputs.version }}"
create_manifest "${v%.*}" "${{ needs.init.outputs.version }}"
fi
+2
View File
@@ -88,6 +88,8 @@ class AbodeCamera(AbodeDevice, Camera):
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Get a camera image."""
if not self.capture():
return None
self.refresh_image()
if self._response:
@@ -822,6 +822,8 @@ class AlexaInputController(AlexaCapability):
"""Return list of supported inputs."""
input_list = []
for source in source_list:
if not isinstance(source, str):
continue
formatted_source = (
source.lower().replace("-", "").replace("_", "").replace(" ", "")
)
@@ -204,13 +204,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Init ip webcam
cam = PyDroidIPCam(
hass.loop,
websession,
host,
cam_config[CONF_PORT],
username=username,
password=password,
timeout=cam_config[CONF_TIMEOUT],
ssl=False,
)
if switches is None:
@@ -2,7 +2,7 @@
"domain": "android_ip_webcam",
"name": "Android IP Webcam",
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
"requirements": ["pydroid-ipcam==0.8"],
"requirements": ["pydroid-ipcam==1.3.1"],
"codeowners": [],
"iot_class": "local_polling"
}
@@ -1,5 +1,4 @@
{
"disabled": "Integration library not compatible with Python 3.10",
"domain": "apcupsd",
"name": "apcupsd",
"documentation": "https://www.home-assistant.io/integrations/apcupsd",
@@ -73,19 +73,24 @@ class CameraMediaSource(MediaSource):
if item.identifier:
raise BrowseError("Unknown item")
supported_stream_types: list[str | None] = [None]
if "stream" in self.hass.config.components:
supported_stream_types.append(STREAM_TYPE_HLS)
can_stream_hls = "stream" in self.hass.config.components
# Root. List cameras.
component: EntityComponent = self.hass.data[DOMAIN]
children = []
not_shown = 0
for camera in component.entities:
camera = cast(Camera, camera)
stream_type = camera.frontend_stream_type
if stream_type not in supported_stream_types:
if stream_type is None:
content_type = camera.content_type
elif can_stream_hls and stream_type == STREAM_TYPE_HLS:
content_type = FORMAT_CONTENT_TYPE[HLS_PROVIDER]
else:
not_shown += 1
continue
children.append(
@@ -93,7 +98,7 @@ class CameraMediaSource(MediaSource):
domain=DOMAIN,
identifier=camera.entity_id,
media_class=MEDIA_CLASS_VIDEO,
media_content_type=FORMAT_CONTENT_TYPE[HLS_PROVIDER],
media_content_type=content_type,
title=camera.name,
thumbnail=f"/api/camera_proxy/{camera.entity_id}",
can_play=True,
@@ -111,4 +116,5 @@ class CameraMediaSource(MediaSource):
can_expand=True,
children_media_class=MEDIA_CLASS_VIDEO,
children=children,
not_shown=not_shown,
)
+1 -1
View File
@@ -2,7 +2,7 @@
"domain": "cloud",
"name": "Home Assistant Cloud",
"documentation": "https://www.home-assistant.io/integrations/cloud",
"requirements": ["hass-nabucasa==0.53.1"],
"requirements": ["hass-nabucasa==0.54.0"],
"dependencies": ["http", "webhook"],
"after_dependencies": ["google_assistant", "alexa"],
"codeowners": ["@home-assistant/cloud"],
+4 -3
View File
@@ -92,14 +92,15 @@ class CO2Sensor(update_coordinator.CoordinatorEntity[CO2SignalResponse], SensorE
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and self.coordinator.data["data"].get(self._description.key) is not None
super().available and self._description.key in self.coordinator.data["data"]
)
@property
def native_value(self) -> StateType:
"""Return sensor state."""
return round(self.coordinator.data["data"][self._description.key], 2) # type: ignore[misc]
if (value := self.coordinator.data["data"][self._description.key]) is None: # type: ignore[misc]
return None
return round(value, 2)
@property
def native_unit_of_measurement(self) -> str | None:
@@ -3,7 +3,7 @@
"name": "Dexcom",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dexcom",
"requirements": ["pydexcom==0.2.2"],
"requirements": ["pydexcom==0.2.3"],
"codeowners": ["@gagebenne"],
"iot_class": "cloud_polling",
"loggers": ["pydexcom"]
+9 -7
View File
@@ -159,7 +159,7 @@ class WatcherBase:
async def async_start(self):
"""Start the watcher."""
def process_client(self, ip_address, hostname, mac_address):
def process_client(self, ip_address: str, hostname: str, mac_address: str) -> None:
"""Process a client."""
return run_callback_threadsafe(
self.hass.loop,
@@ -170,7 +170,9 @@ class WatcherBase:
).result()
@callback
def async_process_client(self, ip_address, hostname, mac_address):
def async_process_client(
self, ip_address: str, hostname: str, mac_address: str
) -> None:
"""Process a client."""
made_ip_address = make_ip_address(ip_address)
@@ -355,15 +357,15 @@ class DeviceTrackerRegisteredWatcher(WatcherBase):
async def async_start(self):
"""Stop watching for device tracker registrations."""
self._unsub = async_dispatcher_connect(
self.hass, CONNECTED_DEVICE_REGISTERED, self._async_process_device_state
self.hass, CONNECTED_DEVICE_REGISTERED, self._async_process_device_data
)
@callback
def _async_process_device_state(self, data: dict[str, Any]) -> None:
def _async_process_device_data(self, data: dict[str, str | None]) -> None:
"""Process a device tracker state."""
ip_address = data.get(ATTR_IP)
hostname = data.get(ATTR_HOST_NAME, "")
mac_address = data.get(ATTR_MAC)
ip_address = data[ATTR_IP]
hostname = data[ATTR_HOST_NAME] or ""
mac_address = data[ATTR_MAC]
if ip_address is None or mac_address is None:
return
+11 -1
View File
@@ -20,9 +20,13 @@ _LOGGER = logging.getLogger(__name__)
ATTR_EMBED = "embed"
ATTR_EMBED_AUTHOR = "author"
ATTR_EMBED_COLOR = "color"
ATTR_EMBED_DESCRIPTION = "description"
ATTR_EMBED_FIELDS = "fields"
ATTR_EMBED_FOOTER = "footer"
ATTR_EMBED_TITLE = "title"
ATTR_EMBED_THUMBNAIL = "thumbnail"
ATTR_EMBED_URL = "url"
ATTR_IMAGES = "images"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_TOKEN): cv.string})
@@ -64,10 +68,16 @@ class DiscordNotificationService(BaseNotificationService):
embeds: list[nextcord.Embed] = []
if ATTR_EMBED in data:
embedding = data[ATTR_EMBED]
title = embedding.get(ATTR_EMBED_TITLE) or nextcord.Embed.Empty
description = embedding.get(ATTR_EMBED_DESCRIPTION) or nextcord.Embed.Empty
color = embedding.get(ATTR_EMBED_COLOR) or nextcord.Embed.Empty
url = embedding.get(ATTR_EMBED_URL) or nextcord.Embed.Empty
fields = embedding.get(ATTR_EMBED_FIELDS) or []
if embedding:
embed = nextcord.Embed(**embedding)
embed = nextcord.Embed(
title=title, description=description, color=color, url=url
)
for field in fields:
embed.add_field(**field)
if ATTR_EMBED_FOOTER in embedding:
+26 -3
View File
@@ -542,7 +542,7 @@ class DmsDeviceSource:
children = await self._device.async_browse_direct_children(
object_id,
metadata_filter=DLNA_BROWSE_FILTER,
sort_criteria=DLNA_SORT_CRITERIA,
sort_criteria=self._sort_criteria,
)
return self._didl_to_media_source(base_object, children)
@@ -575,7 +575,8 @@ class DmsDeviceSource:
children=children,
)
media_source.calculate_children_class()
if media_source.children:
media_source.calculate_children_class()
return media_source
@@ -645,7 +646,8 @@ class DmsDeviceSource:
thumbnail=self._didl_thumbnail_url(item),
)
media_source.calculate_children_class()
if media_source.children:
media_source.calculate_children_class()
return media_source
@@ -673,6 +675,27 @@ class DmsDeviceSource:
"""Make an identifier for BrowseMediaSource."""
return f"{self.source_id}/{action}{object_id}"
@property # type: ignore
@functools.cache
def _sort_criteria(self) -> list[str]:
"""Return criteria to be used for sorting results.
The device must be connected before reading this property.
"""
assert self._device
if self._device.sort_capabilities == ["*"]:
return DLNA_SORT_CRITERIA
# Filter criteria based on what the device supports. Strings in
# DLNA_SORT_CRITERIA are prefixed with a sign, while those in
# the device's sort_capabilities are not.
return [
criterion
for criterion in DLNA_SORT_CRITERIA
if criterion[1:] in self._device.sort_capabilities
]
class Action(StrEnum):
"""Actions that can be specified in a DMS media-source identifier."""
@@ -4,7 +4,8 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"requirements": ["async-upnp-client==0.23.5"],
"dependencies": ["media_source", "ssdp"],
"dependencies": ["ssdp"],
"after_dependencies": ["media_source"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
@@ -26,4 +27,4 @@
"codeowners": ["@chishm"],
"iot_class": "local_polling",
"quality_scale": "platinum"
}
}
+16 -8
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from http import HTTPStatus
import logging
from typing import Any
from aiohttp import web
from doorbirdpy import DoorBird
@@ -166,7 +167,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
async def _async_register_events(hass, doorstation):
async def _async_register_events(
hass: HomeAssistant, doorstation: ConfiguredDoorBird
) -> bool:
try:
await hass.async_add_executor_job(doorstation.register_events, hass)
except requests.exceptions.HTTPError:
@@ -243,7 +246,7 @@ class ConfiguredDoorBird:
"""Get token for device."""
return self._token
def register_events(self, hass):
def register_events(self, hass: HomeAssistant) -> None:
"""Register events on device."""
# Get the URL of this server
hass_url = get_url(hass)
@@ -258,9 +261,10 @@ class ConfiguredDoorBird:
favorites = self.device.favorites()
for event in self.doorstation_events:
self._register_event(hass_url, event, favs=favorites)
_LOGGER.info("Successfully registered URL for %s on %s", event, self.name)
if self._register_event(hass_url, event, favs=favorites):
_LOGGER.info(
"Successfully registered URL for %s on %s", event, self.name
)
@property
def slug(self):
@@ -270,21 +274,25 @@ class ConfiguredDoorBird:
def _get_event_name(self, event):
return f"{self.slug}_{event}"
def _register_event(self, hass_url, event, favs=None):
def _register_event(
self, hass_url: str, event: str, favs: dict[str, Any] | None = None
) -> bool:
"""Add a schedule entry in the device for a sensor."""
url = f"{hass_url}{API_URL}/{event}?token={self._token}"
# Register HA URL as webhook if not already, then get the ID
if self.webhook_is_registered(url, favs=favs):
return
return True
self.device.change_favorite("http", f"Home Assistant ({event})", url)
if not self.webhook_is_registered(url):
_LOGGER.warning(
'Could not find favorite for URL "%s". ' 'Skipping sensor "%s"',
'Unable to set favorite URL "%s". ' 'Event "%s" will not fire',
url,
event,
)
return False
return True
def webhook_is_registered(self, url, favs=None) -> bool:
"""Return whether the given URL is registered as a device favorite."""
+10 -3
View File
@@ -1,13 +1,13 @@
"""Support for Elgato Lights."""
from typing import NamedTuple
from elgato import Elgato, Info, State
from elgato import Elgato, ElgatoConnectionError, Info, State
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
@@ -31,12 +31,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
session=session,
)
async def _async_update_data() -> State:
"""Fetch Elgato data."""
try:
return await elgato.state()
except ElgatoConnectionError as err:
raise UpdateFailed(err) from err
coordinator: DataUpdateCoordinator[State] = DataUpdateCoordinator(
hass,
LOGGER,
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
update_interval=SCAN_INTERVAL,
update_method=elgato.state,
update_method=_async_update_data,
)
await coordinator.async_config_entry_first_refresh()
+23 -8
View File
@@ -228,7 +228,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("Setting up elkm1 %s", conf["host"])
if not entry.unique_id or ":" not in entry.unique_id and is_ip_address(host):
if (not entry.unique_id or ":" not in entry.unique_id) and is_ip_address(host):
_LOGGER.debug(
"Unique id for %s is missing during setup, trying to fill from discovery",
host,
)
if device := await async_discover_device(hass, host):
async_update_entry_from_discovery(hass, entry, device)
@@ -275,9 +279,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
keypad.add_callback(_element_changed)
try:
if not await async_wait_for_elk_to_sync(
elk, LOGIN_TIMEOUT, SYNC_TIMEOUT, conf[CONF_HOST]
):
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, SYNC_TIMEOUT):
return False
except asyncio.TimeoutError as exc:
raise ConfigEntryNotReady(f"Timed out connecting to {conf[CONF_HOST]}") from exc
@@ -327,7 +329,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_wait_for_elk_to_sync(
elk: elkm1.Elk, login_timeout: int, sync_timeout: int, conf_host: str
elk: elkm1.Elk,
login_timeout: int,
sync_timeout: int,
) -> bool:
"""Wait until the elk has finished sync. Can fail login or timeout."""
@@ -347,21 +351,32 @@ async def async_wait_for_elk_to_sync(
login_event.set()
sync_event.set()
def first_response(*args, **kwargs):
_LOGGER.debug("ElkM1 received first response (VN)")
login_event.set()
def sync_complete():
sync_event.set()
success = True
elk.add_handler("login", login_status)
# VN is the first command sent for panel, when we get
# it back we now we are logged in either with or without a password
elk.add_handler("VN", first_response)
elk.add_handler("sync_complete", sync_complete)
events = ((login_event, login_timeout), (sync_event, sync_timeout))
for event, timeout in events:
for name, event, timeout in (
("login", login_event, login_timeout),
("sync_complete", sync_event, sync_timeout),
):
_LOGGER.debug("Waiting for %s event for %s seconds", name, timeout)
try:
async with async_timeout.timeout(timeout):
await event.wait()
except asyncio.TimeoutError:
_LOGGER.debug("Timed out waiting for %s event", name)
elk.disconnect()
raise
_LOGGER.debug("Received %s event", name)
return success
+33 -8
View File
@@ -24,6 +24,7 @@ from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.util import slugify
from homeassistant.util.network import is_ip_address
from . import async_wait_for_elk_to_sync
from .const import CONF_AUTO_CONFIGURE, DISCOVER_SCAN_TIMEOUT, DOMAIN, LOGIN_TIMEOUT
@@ -80,8 +81,11 @@ async def validate_input(data: dict[str, str], mac: str | None) -> dict[str, str
)
elk.connect()
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, VALIDATE_TIMEOUT, url):
raise InvalidAuth
try:
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, VALIDATE_TIMEOUT):
raise InvalidAuth
finally:
elk.disconnect()
short_mac = _short_mac(mac) if mac else None
if prefix and prefix != short_mac:
@@ -124,6 +128,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._discovered_device = ElkSystem(
discovery_info.macaddress, discovery_info.ip, 0
)
_LOGGER.debug("Elk discovered from dhcp: %s", self._discovered_device)
return await self._async_handle_discovery()
async def async_step_integration_discovery(
@@ -135,6 +140,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
discovery_info["ip_address"],
discovery_info["port"],
)
_LOGGER.debug(
"Elk discovered from integration discovery: %s", self._discovered_device
)
return await self._async_handle_discovery()
async def _async_handle_discovery(self) -> FlowResult:
@@ -220,7 +228,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
try:
info = await validate_input(user_input, self.unique_id)
except asyncio.TimeoutError:
return {CONF_HOST: "cannot_connect"}, None
return {"base": "cannot_connect"}, None
except InvalidAuth:
return {CONF_PASSWORD: "invalid_auth"}, None
except Exception: # pylint: disable=broad-except
@@ -280,9 +288,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if device := await async_discover_device(
self.hass, user_input[CONF_ADDRESS]
):
await self.async_set_unique_id(dr.format_mac(device.mac_address))
await self.async_set_unique_id(
dr.format_mac(device.mac_address), raise_on_progress=False
)
self._abort_if_unique_id_configured()
user_input[CONF_ADDRESS] = f"{device.ip_address}:{device.port}"
# Ignore the port from discovery since its always going to be
# 2601 if secure is turned on even though they may want insecure
user_input[CONF_ADDRESS] = device.ip_address
errors, result = await self._async_create_or_error(user_input, False)
if not errors:
return result
@@ -304,11 +316,24 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_import(self, user_input):
"""Handle import."""
if device := await async_discover_device(
self.hass, urlparse(user_input[CONF_HOST]).hostname
_LOGGER.debug("Elk is importing from yaml")
url = _make_url_from_data(user_input)
if self._url_already_configured(url):
return self.async_abort(reason="address_already_configured")
host = urlparse(url).hostname
_LOGGER.debug(
"Importing is trying to fill unique id from discovery for %s", host
)
if is_ip_address(host) and (
device := await async_discover_device(self.hass, host)
):
await self.async_set_unique_id(dr.format_mac(device.mac_address))
await self.async_set_unique_id(
dr.format_mac(device.mac_address), raise_on_progress=False
)
self._abort_if_unique_id_configured()
return (await self._async_create_or_error(user_input, True))[1]
def _url_already_configured(self, url):
+1 -1
View File
@@ -9,7 +9,7 @@ from homeassistant.const import ATTR_CODE, CONF_ZONE
DOMAIN = "elkm1"
LOGIN_TIMEOUT = 15
LOGIN_TIMEOUT = 20
CONF_AUTO_CONFIGURE = "auto_configure"
CONF_AREA = "area"
@@ -29,9 +29,11 @@ def async_update_entry_from_discovery(
) -> bool:
"""Update a config entry from a discovery."""
if not entry.unique_id or ":" not in entry.unique_id:
_LOGGER.debug("Adding unique id from discovery: %s", device)
return hass.config_entries.async_update_entry(
entry, unique_id=dr.format_mac(device.mac_address)
)
_LOGGER.debug("Unique id is already present from discovery: %s", device)
return False
+10 -3
View File
@@ -3,6 +3,7 @@ from datetime import timedelta
import logging
from flipr_api import FliprAPIRestClient
from flipr_api.exceptions import FliprError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
@@ -11,6 +12,7 @@ from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER, NAME
@@ -68,9 +70,14 @@ class FliprDataUpdateCoordinator(DataUpdateCoordinator):
async def _async_update_data(self):
"""Fetch data from API endpoint."""
return await self.hass.async_add_executor_job(
self.client.get_pool_measure_latest, self.flipr_id
)
try:
data = await self.hass.async_add_executor_job(
self.client.get_pool_measure_latest, self.flipr_id
)
except (FliprError) as error:
raise UpdateFailed(error) from error
return data
class FliprEntity(CoordinatorEntity):
+1 -1
View File
@@ -4,7 +4,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/flipr",
"requirements": [
"flipr-api==1.4.1"],
"flipr-api==1.4.2"],
"codeowners": [
"@cnico"
],
+1 -1
View File
@@ -64,7 +64,7 @@ async def async_setup_entry(
coordinator, base_unique_id, f"{name} Operating Mode", "operating_mode"
)
)
if device.wirings:
if device.wirings and device.wiring is not None:
entities.append(
FluxWiringsSelect(coordinator, base_unique_id, f"{name} Wiring", "wiring")
)
@@ -33,6 +33,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except FRITZ_EXCEPTIONS as ex:
raise ConfigEntryNotReady from ex
if (
"X_AVM-DE_UPnP1" in avm_wrapper.connection.services
and not (await avm_wrapper.async_get_upnp_configuration())["NewEnable"]
):
raise ConfigEntryAuthFailed("Missing UPnP configuration")
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = avm_wrapper
@@ -1,6 +1,7 @@
"""AVM FRITZ!Box connectivity sensor."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
@@ -14,8 +15,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .common import AvmWrapper, FritzBoxBaseEntity
from .const import DOMAIN, MeshRoles
from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -24,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
class FritzBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Fritz sensor entity."""
exclude_mesh_role: MeshRoles = MeshRoles.SLAVE
is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled
SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = (
@@ -45,7 +46,7 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = (
name="Firmware Update",
device_class=BinarySensorDeviceClass.UPDATE,
entity_category=EntityCategory.DIAGNOSTIC,
exclude_mesh_role=MeshRoles.NONE,
is_suitable=lambda info: True,
),
)
@@ -57,10 +58,12 @@ async def async_setup_entry(
_LOGGER.debug("Setting up FRITZ!Box binary sensors")
avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
connection_info = await avm_wrapper.async_get_connection_info()
entities = [
FritzBoxBinarySensor(avm_wrapper, entry.title, description)
for description in SENSOR_TYPES
if (description.exclude_mesh_role != avm_wrapper.mesh_role)
if description.is_suitable(connection_info)
]
async_add_entities(entities, True)
+37
View File
@@ -392,6 +392,8 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator):
)
self.mesh_role = MeshRoles.NONE
for mac, info in hosts.items():
if info.ip_address:
info.wan_access = self._get_wan_access(info.ip_address)
if self.manage_device_info(info, mac, consider_home):
new_device = True
self.send_signal_device_update(new_device)
@@ -630,6 +632,11 @@ class AvmWrapper(FritzBoxTools):
)
return {}
async def async_get_upnp_configuration(self) -> dict[str, Any]:
"""Call X_AVM-DE_UPnP service."""
return await self.hass.async_add_executor_job(self.get_upnp_configuration)
async def async_get_wan_link_properties(self) -> dict[str, Any]:
"""Call WANCommonInterfaceConfig service."""
@@ -637,6 +644,22 @@ class AvmWrapper(FritzBoxTools):
partial(self.get_wan_link_properties)
)
async def async_get_connection_info(self) -> ConnectionInfo:
"""Return ConnectionInfo data."""
link_properties = await self.async_get_wan_link_properties()
connection_info = ConnectionInfo(
connection=link_properties.get("NewWANAccessType", "").lower(),
mesh_role=self.mesh_role,
wan_enabled=self.device_is_router,
)
_LOGGER.debug(
"ConnectionInfo for FritzBox %s: %s",
self.host,
connection_info,
)
return connection_info
async def async_get_port_mapping(self, con_type: str, index: int) -> dict[str, Any]:
"""Call GetGenericPortMappingEntry action."""
@@ -698,6 +721,11 @@ class AvmWrapper(FritzBoxTools):
partial(self.set_allow_wan_access, ip_address, turn_on)
)
def get_upnp_configuration(self) -> dict[str, Any]:
"""Call X_AVM-DE_UPnP service."""
return self._service_call_action("X_AVM-DE_UPnP", "1", "GetInfo")
def get_ontel_num_deflections(self) -> dict[str, Any]:
"""Call GetNumberOfDeflections action from X_AVM-DE_OnTel service."""
@@ -960,3 +988,12 @@ class FritzBoxBaseEntity:
name=self._device_name,
sw_version=self._avm_wrapper.current_firmware,
)
@dataclass
class ConnectionInfo:
"""Fritz sensor connection information class."""
connection: str
mesh_role: MeshRoles
wan_enabled: bool
@@ -29,6 +29,7 @@ from .const import (
ERROR_AUTH_INVALID,
ERROR_CANNOT_CONNECT,
ERROR_UNKNOWN,
ERROR_UPNP_NOT_CONFIGURED,
)
_LOGGER = logging.getLogger(__name__)
@@ -79,6 +80,12 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
return ERROR_UNKNOWN
if (
"X_AVM-DE_UPnP1" in self.avm_wrapper.connection.services
and not (await self.avm_wrapper.async_get_upnp_configuration())["NewEnable"]
):
return ERROR_UPNP_NOT_CONFIGURED
return None
async def async_check_configured_entry(self) -> ConfigEntry | None:
+2
View File
@@ -46,6 +46,7 @@ DEFAULT_USERNAME = ""
ERROR_AUTH_INVALID = "invalid_auth"
ERROR_CANNOT_CONNECT = "cannot_connect"
ERROR_UPNP_NOT_CONFIGURED = "upnp_not_configured"
ERROR_UNKNOWN = "unknown_error"
FRITZ_SERVICES = "fritz_services"
@@ -56,6 +57,7 @@ SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password"
SWITCH_TYPE_DEFLECTION = "CallDeflection"
SWITCH_TYPE_PORTFORWARD = "PortForward"
SWITCH_TYPE_PROFILE = "Profile"
SWITCH_TYPE_WIFINETWORK = "WiFiNetwork"
UPTIME_DEVIATION = 5
@@ -22,6 +22,9 @@ async def async_get_config_entry_diagnostics(
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
"device_info": {
"model": avm_wrapper.model,
"unique_id": avm_wrapper.unique_id.replace(
avm_wrapper.unique_id[6:11], "XX:XX"
),
"current_firmware": avm_wrapper.current_firmware,
"latest_firmware": avm_wrapper.latest_firmware,
"update_available": avm_wrapper.update_available,
+3 -23
View File
@@ -28,8 +28,8 @@ from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.dt import utcnow
from .common import AvmWrapper, FritzBoxBaseEntity
from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION, MeshRoles
from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity
from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION
_LOGGER = logging.getLogger(__name__)
@@ -134,15 +134,6 @@ def _retrieve_link_attenuation_received_state(
return status.attenuation[1] / 10 # type: ignore[no-any-return]
@dataclass
class ConnectionInfo:
"""Fritz sensor connection information class."""
connection: str
mesh_role: MeshRoles
wan_enabled: bool
@dataclass
class FritzRequireKeysMixin:
"""Fritz sensor data class."""
@@ -283,18 +274,7 @@ async def async_setup_entry(
_LOGGER.debug("Setting up FRITZ!Box sensors")
avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
link_properties = await avm_wrapper.async_get_wan_link_properties()
connection_info = ConnectionInfo(
connection=link_properties.get("NewWANAccessType", "").lower(),
mesh_role=avm_wrapper.mesh_role,
wan_enabled=avm_wrapper.device_is_router,
)
_LOGGER.debug(
"ConnectionInfo for FritzBox %s: %s",
avm_wrapper.host,
connection_info,
)
connection_info = await avm_wrapper.async_get_connection_info()
entities = [
FritzBoxSensor(avm_wrapper, entry.title, description)
@@ -36,6 +36,7 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"upnp_not_configured": "Missing UPnP settings on device.",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
+6
View File
@@ -30,6 +30,7 @@ from .const import (
DOMAIN,
SWITCH_TYPE_DEFLECTION,
SWITCH_TYPE_PORTFORWARD,
SWITCH_TYPE_PROFILE,
SWITCH_TYPE_WIFINETWORK,
WIFI_STANDARD,
MeshRoles,
@@ -185,6 +186,7 @@ def profile_entities_list(
data_fritz: FritzData,
) -> list[FritzBoxProfileSwitch]:
"""Add new tracker entities from the AVM device."""
_LOGGER.debug("Setting up %s switches", SWITCH_TYPE_PROFILE)
new_profiles: list[FritzBoxProfileSwitch] = []
@@ -198,11 +200,15 @@ def profile_entities_list(
if device_filter_out_from_trackers(
mac, device, data_fritz.profile_switches.values()
):
_LOGGER.debug(
"Skipping profile switch creation for device %s", device.hostname
)
continue
new_profiles.append(FritzBoxProfileSwitch(avm_wrapper, device))
data_fritz.profile_switches[avm_wrapper.unique_id].add(mac)
_LOGGER.debug("Creating %s profile switches", len(new_profiles))
return new_profiles
@@ -9,7 +9,8 @@
"already_configured": "Device is already configured",
"already_in_progress": "Configuration flow is already in progress",
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication"
"invalid_auth": "Invalid authentication",
"upnp_not_configured": "Missing UPnP settings on device."
},
"flow_title": "{name}",
"step": {
@@ -51,4 +52,4 @@
}
}
}
}
}
@@ -7,7 +7,7 @@ import json
import logging
import os
import pathlib
from typing import Any, TypedDict, cast
from typing import Any, TypedDict
from aiohttp import hdrs, web, web_urldispatcher
import jinja2
@@ -313,7 +313,7 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path:
# pylint: disable=import-outside-toplevel
import hass_frontend
return cast(pathlib.Path, hass_frontend.where())
return hass_frontend.where()
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -3,7 +3,7 @@
"name": "Home Assistant Frontend",
"documentation": "https://www.home-assistant.io/integrations/frontend",
"requirements": [
"home-assistant-frontend==20220223.0"
"home-assistant-frontend==20220301.1"
],
"dependencies": [
"api",
+5 -4
View File
@@ -59,11 +59,12 @@ SERVICE_SET = "set"
SERVICE_REMOVE = "remove"
PLATFORMS = [
Platform.LIGHT,
Platform.COVER,
Platform.NOTIFY,
Platform.FAN,
Platform.BINARY_SENSOR,
Platform.COVER,
Platform.FAN,
Platform.LIGHT,
Platform.MEDIA_PLAYER,
Platform.NOTIFY,
]
REG_KEY = f"{DOMAIN}_registry"
@@ -17,6 +17,7 @@ from homeassistant.const import (
CONF_UNIQUE_ID,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Event, HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
@@ -80,7 +81,6 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity):
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
self._device_class = device_class
self._state: str | None = None
self.mode = any
if mode:
self.mode = all
@@ -106,13 +106,23 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity):
def async_update_group_state(self) -> None:
"""Query all members and determine the binary sensor group state."""
all_states = [self.hass.states.get(x) for x in self._entity_ids]
# filtered_states are members currently in the state machine
filtered_states: list[str] = [x.state for x in all_states if x is not None]
# Set group as unavailable if all members are unavailable
self._attr_available = any(
state != STATE_UNAVAILABLE for state in filtered_states
)
if STATE_UNAVAILABLE in filtered_states:
valid_state = self.mode(
state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in filtered_states
)
if not valid_state:
# Set as unknown if any / all member is not unknown or unavailable
self._attr_is_on = None
else:
# Set as ON if any / all member is ON
states = list(map(lambda x: x == STATE_ON, filtered_states))
state = self.mode(states)
self._attr_is_on = state
@@ -221,12 +221,9 @@ class GrowattData:
# Create datetime from the latest entry
date_now = dt.now().date()
last_updated_time = dt.parse_time(str(sorted_keys[-1]))
combined_timestamp = datetime.datetime.combine(
date_now, last_updated_time
mix_detail["lastdataupdate"] = datetime.datetime.combine(
date_now, last_updated_time, dt.DEFAULT_TIME_ZONE
)
# Convert datetime to UTC
combined_timestamp_utc = dt.as_utc(combined_timestamp)
mix_detail["lastdataupdate"] = combined_timestamp_utc.isoformat()
# Dashboard data is largely inaccurate for mix system but it is the only call with the ability to return the combined
# imported from grid value that is the combination of charging AND load consumption
@@ -274,7 +274,7 @@ class HomeAccessory(Accessory):
if self.config.get(ATTR_SW_VERSION) is not None:
sw_version = format_version(self.config[ATTR_SW_VERSION])
if sw_version is None:
sw_version = __version__
sw_version = format_version(__version__)
hw_version = None
if self.config.get(ATTR_HW_VERSION) is not None:
hw_version = format_version(self.config[ATTR_HW_VERSION])
@@ -289,7 +289,9 @@ class HomeAccessory(Accessory):
serv_info = self.get_service(SERV_ACCESSORY_INFO)
char = self.driver.loader.get_char(CHAR_HARDWARE_REVISION)
serv_info.add_characteristic(char)
serv_info.configure_char(CHAR_HARDWARE_REVISION, value=hw_version)
serv_info.configure_char(
CHAR_HARDWARE_REVISION, value=hw_version[:MAX_VERSION_LENGTH]
)
self.iid_manager.assign(char)
char.broker = self
@@ -532,7 +534,7 @@ class HomeBridge(Bridge):
"""Initialize a Bridge object."""
super().__init__(driver, name)
self.set_info_service(
firmware_revision=__version__,
firmware_revision=format_version(__version__),
manufacturer=MANUFACTURER,
model=BRIDGE_MODEL,
serial_number=BRIDGE_SERIAL_NUMBER,
@@ -8,8 +8,8 @@
"PyQRCode==1.2.1",
"base36==0.1.1"
],
"dependencies": ["http", "camera", "ffmpeg", "network"],
"after_dependencies": ["zeroconf"],
"dependencies": ["ffmpeg", "http", "network"],
"after_dependencies": ["camera", "zeroconf"],
"codeowners": ["@bdraco"],
"zeroconf": ["_homekit._tcp.local."],
"config_flow": true,
@@ -285,20 +285,19 @@ class Thermostat(HomeAccessory):
CHAR_CURRENT_HUMIDITY, value=50
)
fan_modes = self.fan_modes = {
fan_mode.lower(): fan_mode
for fan_mode in attributes.get(ATTR_FAN_MODES, [])
}
fan_modes = {}
self.ordered_fan_speeds = []
if (
features & SUPPORT_FAN_MODE
and fan_modes
and PRE_DEFINED_FAN_MODES.intersection(fan_modes)
):
self.ordered_fan_speeds = [
speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes
]
self.fan_chars.append(CHAR_ROTATION_SPEED)
if features & SUPPORT_FAN_MODE:
fan_modes = {
fan_mode.lower(): fan_mode
for fan_mode in attributes.get(ATTR_FAN_MODES) or []
}
if fan_modes and PRE_DEFINED_FAN_MODES.intersection(fan_modes):
self.ordered_fan_speeds = [
speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes
]
self.fan_chars.append(CHAR_ROTATION_SPEED)
if FAN_AUTO in fan_modes and (FAN_ON in fan_modes or self.ordered_fan_speeds):
self.fan_chars.append(CHAR_TARGET_FAN_STATE)
+25 -6
View File
@@ -100,6 +100,7 @@ _LOGGER = logging.getLogger(__name__)
NUMBERS_ONLY_RE = re.compile(r"[^\d.]+")
VERSION_RE = re.compile(r"([0-9]+)(\.[0-9]+)?(\.[0-9]+)?")
MAX_VERSION_PART = 2**32 - 1
MAX_PORT = 65535
@@ -363,7 +364,15 @@ def convert_to_float(state):
return None
def cleanup_name_for_homekit(name: str | None) -> str | None:
def coerce_int(state: str) -> int:
"""Return int."""
try:
return int(state)
except (ValueError, TypeError):
return 0
def cleanup_name_for_homekit(name: str | None) -> str:
"""Ensure the name of the device will not crash homekit."""
#
# This is not a security measure.
@@ -371,7 +380,7 @@ def cleanup_name_for_homekit(name: str | None) -> str | None:
# UNICODE_EMOJI is also not allowed but that
# likely isn't a problem
if name is None:
return None
return "None" # None crashes apple watches
return name.translate(HOMEKIT_CHAR_TRANSLATIONS)[:MAX_NAME_LENGTH]
@@ -420,13 +429,23 @@ def get_aid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str):
)
def _format_version_part(version_part: str) -> str:
return str(max(0, min(MAX_VERSION_PART, coerce_int(version_part))))
def format_version(version):
"""Extract the version string in a format homekit can consume."""
split_ver = str(version).replace("-", ".")
split_ver = str(version).replace("-", ".").replace(" ", ".")
num_only = NUMBERS_ONLY_RE.sub("", split_ver)
if match := VERSION_RE.search(num_only):
return match.group(0)
return None
if (match := VERSION_RE.search(num_only)) is None:
return None
value = ".".join(map(_format_version_part, match.group(0).split(".")))
return None if _is_zero_but_true(value) else value
def _is_zero_but_true(value):
"""Zero but true values can crash apple watches."""
return convert_to_float(value) == 0
def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str):
@@ -3,7 +3,7 @@
"name": "HomeKit Controller",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"requirements": ["aiohomekit==0.7.14"],
"requirements": ["aiohomekit==0.7.16"],
"zeroconf": ["_hap._tcp.local."],
"after_dependencies": ["zeroconf"],
"codeowners": ["@Jc2k", "@bdraco"],
@@ -48,42 +48,6 @@ NUMBER_ENTITIES: dict[str, NumberEntityDescription] = {
icon="mdi:volume-high",
entity_category=EntityCategory.CONFIG,
),
CharacteristicsTypes.VENDOR_ECOBEE_HOME_TARGET_COOL: NumberEntityDescription(
key=CharacteristicsTypes.VENDOR_ECOBEE_HOME_TARGET_COOL,
name="Home Cool Target",
icon="mdi:thermometer-minus",
entity_category=EntityCategory.CONFIG,
),
CharacteristicsTypes.VENDOR_ECOBEE_HOME_TARGET_HEAT: NumberEntityDescription(
key=CharacteristicsTypes.VENDOR_ECOBEE_HOME_TARGET_HEAT,
name="Home Heat Target",
icon="mdi:thermometer-plus",
entity_category=EntityCategory.CONFIG,
),
CharacteristicsTypes.VENDOR_ECOBEE_SLEEP_TARGET_COOL: NumberEntityDescription(
key=CharacteristicsTypes.VENDOR_ECOBEE_SLEEP_TARGET_COOL,
name="Sleep Cool Target",
icon="mdi:thermometer-minus",
entity_category=EntityCategory.CONFIG,
),
CharacteristicsTypes.VENDOR_ECOBEE_SLEEP_TARGET_HEAT: NumberEntityDescription(
key=CharacteristicsTypes.VENDOR_ECOBEE_SLEEP_TARGET_HEAT,
name="Sleep Heat Target",
icon="mdi:thermometer-plus",
entity_category=EntityCategory.CONFIG,
),
CharacteristicsTypes.VENDOR_ECOBEE_AWAY_TARGET_COOL: NumberEntityDescription(
key=CharacteristicsTypes.VENDOR_ECOBEE_AWAY_TARGET_COOL,
name="Away Cool Target",
icon="mdi:thermometer-minus",
entity_category=EntityCategory.CONFIG,
),
CharacteristicsTypes.VENDOR_ECOBEE_AWAY_TARGET_HEAT: NumberEntityDescription(
key=CharacteristicsTypes.VENDOR_ECOBEE_AWAY_TARGET_HEAT,
name="Away Heat Target",
icon="mdi:thermometer-plus",
entity_category=EntityCategory.CONFIG,
),
}
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "Philips Hue",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hue",
"requirements": ["aiohue==4.2.1"],
"requirements": ["aiohue==4.3.0"],
"ssdp": [
{
"manufacturer": "Royal Philips Electronics",
@@ -3,7 +3,7 @@
"name": "Insteon",
"documentation": "https://www.home-assistant.io/integrations/insteon",
"requirements": [
"pyinsteon==1.0.16"
"pyinsteon==1.0.13"
],
"codeowners": [
"@teharris1"
+25 -1
View File
@@ -1,7 +1,9 @@
"""Support for media browsing."""
import asyncio
import contextlib
import logging
from homeassistant.components import media_source
from homeassistant.components.media_player import BrowseError, BrowseMedia
from homeassistant.components.media_player.const import (
MEDIA_CLASS_ALBUM,
@@ -184,7 +186,16 @@ async def item_payload(item, get_thumbnail_url=None):
)
async def library_payload():
def media_source_content_filter(item: BrowseMedia) -> bool:
"""Content filter for media sources."""
# Filter out cameras using PNG over MJPEG. They don't work in Kodi.
return not (
item.media_content_id.startswith("media-source://camera/")
and item.media_content_type == "image/png"
)
async def library_payload(hass):
"""
Create response payload to describe contents of a specific library.
@@ -222,6 +233,19 @@ async def library_payload():
)
)
for child in library_info.children:
child.thumbnail = "https://brands.home-assistant.io/_/kodi/logo.png"
with contextlib.suppress(media_source.BrowseError):
item = await media_source.async_browse_media(
hass, None, content_filter=media_source_content_filter
)
# If domain is None, it's overview of available sources
if item.domain is None:
library_info.children.extend(item.children)
else:
library_info.children.append(item)
return library_info
@@ -2,6 +2,7 @@
"domain": "kodi",
"name": "Kodi",
"documentation": "https://www.home-assistant.io/integrations/kodi",
"after_dependencies": ["media_source"],
"requirements": ["pykodi==0.2.7"],
"codeowners": ["@OnFreund", "@cgtobi"],
"zeroconf": ["_xbmc-jsonrpc-h._tcp.local."],
+32 -5
View File
@@ -5,6 +5,7 @@ from datetime import timedelta
from functools import wraps
import logging
import re
from typing import Any
import urllib.parse
import jsonrpc_base
@@ -12,7 +13,11 @@ from jsonrpc_base.jsonrpc import ProtocolError, TransportError
from pykodi import CannotConnectError
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
from homeassistant.components.media_player.browse_media import (
async_process_play_media_url,
)
from homeassistant.components.media_player.const import (
MEDIA_TYPE_ALBUM,
MEDIA_TYPE_ARTIST,
@@ -24,6 +29,7 @@ from homeassistant.components.media_player.const import (
MEDIA_TYPE_SEASON,
MEDIA_TYPE_TRACK,
MEDIA_TYPE_TVSHOW,
MEDIA_TYPE_URL,
MEDIA_TYPE_VIDEO,
SUPPORT_BROWSE_MEDIA,
SUPPORT_NEXT_TRACK,
@@ -71,7 +77,12 @@ from homeassistant.helpers.network import is_internal_request
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util
from .browse_media import build_item_response, get_media_info, library_payload
from .browse_media import (
build_item_response,
get_media_info,
library_payload,
media_source_content_filter,
)
from .const import (
CONF_WS_PORT,
DATA_CONNECTION,
@@ -691,16 +702,25 @@ class KodiEntity(MediaPlayerEntity):
await self._kodi.media_seek(position)
@cmd
async def async_play_media(self, media_type, media_id, **kwargs):
async def async_play_media(
self, media_type: str, media_id: str, **kwargs: Any
) -> None:
"""Send the play_media command to the media player."""
if media_source.is_media_source_id(media_id):
media_type = MEDIA_TYPE_URL
play_item = await media_source.async_resolve_media(self.hass, media_id)
media_id = play_item.url
media_type_lower = media_type.lower()
if media_type_lower == MEDIA_TYPE_CHANNEL:
await self._kodi.play_channel(int(media_id))
elif media_type_lower == MEDIA_TYPE_PLAYLIST:
await self._kodi.play_playlist(int(media_id))
elif media_type_lower == "file":
await self._kodi.play_file(media_id)
elif media_type_lower == "directory":
await self._kodi.play_directory(str(media_id))
await self._kodi.play_directory(media_id)
elif media_type_lower in [
MEDIA_TYPE_ARTIST,
MEDIA_TYPE_ALBUM,
@@ -719,7 +739,9 @@ class KodiEntity(MediaPlayerEntity):
{MAP_KODI_MEDIA_TYPES[media_type_lower]: int(media_id)}
)
else:
await self._kodi.play_file(str(media_id))
media_id = async_process_play_media_url(self.hass, media_id)
await self._kodi.play_file(media_id)
@cmd
async def async_set_shuffle(self, shuffle):
@@ -898,7 +920,12 @@ class KodiEntity(MediaPlayerEntity):
)
if media_content_type in [None, "library"]:
return await library_payload()
return await library_payload(self.hass)
if media_source.is_media_source_id(media_content_id):
return await media_source.async_browse_media(
self.hass, media_content_id, content_filter=media_source_content_filter
)
payload = {
"search_type": media_content_type,
@@ -0,0 +1,42 @@
"""Diagnostics support for Kostal Plenticore."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import REDACTED, async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .helper import Plenticore
TO_REDACT = {CONF_PASSWORD}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, dict[str, Any]]:
"""Return diagnostics for a config entry."""
data = {"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT)}
plenticore: Plenticore = hass.data[DOMAIN][config_entry.entry_id]
# Get information from Kostal Plenticore library
available_process_data = await plenticore.client.get_process_data()
available_settings_data = await plenticore.client.get_settings()
data["client"] = {
"version": str(await plenticore.client.get_version()),
"me": str(await plenticore.client.get_me()),
"available_process_data": available_process_data,
"available_settings_data": {
module_id: [str(setting) for setting in settings]
for module_id, settings in available_settings_data.items()
},
}
device_info = {**plenticore.device_info}
device_info["identifiers"] = REDACTED # contains serial number
data["device"] = device_info
return data
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "LIFX",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lifx",
"requirements": ["aiolifx==0.7.0", "aiolifx_effects==0.2.2"],
"requirements": ["aiolifx==0.7.1", "aiolifx_effects==0.2.2"],
"homekit": {
"models": ["LIFX"]
},
@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from urllib.parse import quote
import yarl
@@ -15,7 +16,9 @@ from .const import CONTENT_AUTH_EXPIRY_TIME, MEDIA_CLASS_DIRECTORY
@callback
def async_process_play_media_url(hass: HomeAssistant, media_content_id: str) -> str:
def async_process_play_media_url(
hass: HomeAssistant, media_content_id: str, *, allow_relative_url: bool = False
) -> str:
"""Update a media URL with authentication if it points at Home Assistant."""
if media_content_id[0] != "/" and not is_hass_url(hass, media_content_id):
return media_content_id
@@ -34,8 +37,8 @@ def async_process_play_media_url(hass: HomeAssistant, media_content_id: str) ->
)
media_content_id = str(parsed.join(yarl.URL(signed_path)))
# prepend external URL
if media_content_id[0] == "/":
# convert relative URL to absolute URL
if media_content_id[0] == "/" and not allow_relative_url:
media_content_id = f"{get_url(hass)}{media_content_id}"
return media_content_id
@@ -72,11 +75,15 @@ class BrowseMedia:
def as_dict(self, *, parent: bool = True) -> dict:
"""Convert Media class to browse media dictionary."""
response = {
if self.children_media_class is None and self.children:
self.calculate_children_class()
response: dict[str, Any] = {
"title": self.title,
"media_class": self.media_class,
"media_content_type": self.media_content_type,
"media_content_id": self.media_content_id,
"children_media_class": self.children_media_class,
"can_play": self.can_play,
"can_expand": self.can_expand,
"thumbnail": self.thumbnail,
@@ -85,11 +92,7 @@ class BrowseMedia:
if not parent:
return response
if self.children_media_class is None:
self.calculate_children_class()
response["not_shown"] = self.not_shown
response["children_media_class"] = self.children_media_class
if self.children:
response["children"] = [
@@ -102,11 +105,8 @@ class BrowseMedia:
def calculate_children_class(self) -> None:
"""Count the children media classes and calculate the correct class."""
if self.children is None or len(self.children) == 0:
return
self.children_media_class = MEDIA_CLASS_DIRECTORY
assert self.children is not None
proposed_class = self.children[0].media_class
if all(child.media_class == proposed_class for child in self.children):
self.children_media_class = proposed_class
@@ -2,21 +2,20 @@
from __future__ import annotations
from collections.abc import Callable
import dataclasses
from datetime import timedelta
from typing import Any
from urllib.parse import quote
import voluptuous as vol
from homeassistant.components import frontend, websocket_api
from homeassistant.components.http.auth import async_sign_path
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
CONTENT_AUTH_EXPIRY_TIME,
BrowseError,
BrowseMedia,
)
from homeassistant.components.media_player.browse_media import (
async_process_play_media_url,
)
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.integration_platform import (
@@ -85,11 +84,16 @@ def _get_media_item(
) -> MediaSourceItem:
"""Return media item."""
if media_content_id:
return MediaSourceItem.from_uri(hass, media_content_id)
item = MediaSourceItem.from_uri(hass, media_content_id)
else:
# We default to our own domain if its only one registered
domain = None if len(hass.data[DOMAIN]) > 1 else DOMAIN
return MediaSourceItem(hass, domain, "")
# We default to our own domain if its only one registered
domain = None if len(hass.data[DOMAIN]) > 1 else DOMAIN
return MediaSourceItem(hass, domain, "")
if item.domain is not None and item.domain not in hass.data[DOMAIN]:
raise ValueError("Unknown media source")
return item
@bind_hass
@@ -106,7 +110,7 @@ async def async_browse_media(
try:
item = await _get_media_item(hass, media_content_id).async_browse()
except ValueError as err:
raise BrowseError("Not a media source item") from err
raise BrowseError(str(err)) from err
if content_filter is None or item.children is None:
return item
@@ -115,7 +119,7 @@ async def async_browse_media(
item.children = [
child for child in item.children if child.can_expand or content_filter(child)
]
item.not_shown = old_count - len(item.children)
item.not_shown += old_count - len(item.children)
return item
@@ -128,7 +132,7 @@ async def async_resolve_media(hass: HomeAssistant, media_content_id: str) -> Pla
try:
item = _get_media_item(hass, media_content_id)
except ValueError as err:
raise Unresolvable("Not a media source item") from err
raise Unresolvable(str(err)) from err
return await item.async_resolve()
@@ -172,13 +176,12 @@ async def websocket_resolve_media(
connection.send_error(msg["id"], "resolve_media_failed", str(err))
return
data = dataclasses.asdict(media)
if data["url"][0] == "/":
data["url"] = async_sign_path(
hass,
quote(data["url"]),
timedelta(seconds=msg["expires"]),
)
connection.send_result(msg["id"], data)
connection.send_result(
msg["id"],
{
"url": async_process_play_media_url(
hass, media.url, allow_relative_url=True
),
"mime_type": media.mime_type,
},
)
@@ -2,7 +2,7 @@
"domain": "mediaroom",
"name": "Mediaroom",
"documentation": "https://www.home-assistant.io/integrations/mediaroom",
"requirements": ["pymediaroom==0.6.4.1"],
"requirements": ["pymediaroom==0.6.5.4"],
"codeowners": ["@dgomes"],
"iot_class": "local_polling",
"loggers": ["pymediaroom"]
@@ -28,6 +28,7 @@ ATTR_CONFIG_ENTRY_ID = "entry_id"
ATTR_DEVICE_NAME = "device_name"
ATTR_MANUFACTURER = "manufacturer"
ATTR_MODEL = "model"
ATTR_NO_LEGACY_ENCRYPTION = "no_legacy_encryption"
ATTR_OS_NAME = "os_name"
ATTR_OS_VERSION = "os_version"
ATTR_PUSH_WEBSOCKET_CHANNEL = "push_websocket_channel"
+52 -22
View File
@@ -7,7 +7,7 @@ import json
import logging
from aiohttp.web import Response, json_response
from nacl.encoding import Base64Encoder
from nacl.encoding import Base64Encoder, HexEncoder, RawEncoder
from nacl.secret import SecretBox
from homeassistant.const import ATTR_DEVICE_ID, CONTENT_TYPE_JSON
@@ -23,6 +23,7 @@ from .const import (
ATTR_DEVICE_NAME,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_NO_LEGACY_ENCRYPTION,
ATTR_OS_VERSION,
ATTR_SUPPORTS_ENCRYPTION,
CONF_SECRET,
@@ -34,7 +35,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
def setup_decrypt() -> tuple[int, Callable]:
def setup_decrypt(key_encoder) -> tuple[int, Callable]:
"""Return decryption function and length of key.
Async friendly.
@@ -42,12 +43,14 @@ def setup_decrypt() -> tuple[int, Callable]:
def decrypt(ciphertext, key):
"""Decrypt ciphertext using key."""
return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder)
return SecretBox(key, encoder=key_encoder).decrypt(
ciphertext, encoder=Base64Encoder
)
return (SecretBox.KEY_SIZE, decrypt)
def setup_encrypt() -> tuple[int, Callable]:
def setup_encrypt(key_encoder) -> tuple[int, Callable]:
"""Return encryption function and length of key.
Async friendly.
@@ -55,15 +58,22 @@ def setup_encrypt() -> tuple[int, Callable]:
def encrypt(ciphertext, key):
"""Encrypt ciphertext using key."""
return SecretBox(key).encrypt(ciphertext, encoder=Base64Encoder)
return SecretBox(key, encoder=key_encoder).encrypt(
ciphertext, encoder=Base64Encoder
)
return (SecretBox.KEY_SIZE, encrypt)
def _decrypt_payload(key: str | None, ciphertext: str) -> dict[str, str] | None:
def _decrypt_payload_helper(
key: str | None,
ciphertext: str,
get_key_bytes: Callable[[str, int], str | bytes],
key_encoder,
) -> dict[str, str] | None:
"""Decrypt encrypted payload."""
try:
keylen, decrypt = setup_decrypt()
keylen, decrypt = setup_decrypt(key_encoder)
except OSError:
_LOGGER.warning("Ignoring encrypted payload because libsodium not installed")
return None
@@ -72,18 +82,33 @@ def _decrypt_payload(key: str | None, ciphertext: str) -> dict[str, str] | None:
_LOGGER.warning("Ignoring encrypted payload because no decryption key known")
return None
key_bytes = key.encode("utf-8")
key_bytes = key_bytes[:keylen]
key_bytes = key_bytes.ljust(keylen, b"\0")
key_bytes = get_key_bytes(key, keylen)
try:
msg_bytes = decrypt(ciphertext, key_bytes)
message = json.loads(msg_bytes.decode("utf-8"))
_LOGGER.debug("Successfully decrypted mobile_app payload")
return message
except ValueError:
_LOGGER.warning("Ignoring encrypted payload because unable to decrypt")
return None
msg_bytes = decrypt(ciphertext, key_bytes)
message = json.loads(msg_bytes.decode("utf-8"))
_LOGGER.debug("Successfully decrypted mobile_app payload")
return message
def _decrypt_payload(key: str | None, ciphertext: str) -> dict[str, str] | None:
"""Decrypt encrypted payload."""
def get_key_bytes(key: str, keylen: int) -> str:
return key
return _decrypt_payload_helper(key, ciphertext, get_key_bytes, HexEncoder)
def _decrypt_payload_legacy(key: str | None, ciphertext: str) -> dict[str, str] | None:
"""Decrypt encrypted payload."""
def get_key_bytes(key: str, keylen: int) -> bytes:
key_bytes = key.encode("utf-8")
key_bytes = key_bytes[:keylen]
key_bytes = key_bytes.ljust(keylen, b"\0")
return key_bytes
return _decrypt_payload_helper(key, ciphertext, get_key_bytes, RawEncoder)
def registration_context(registration: dict) -> Context:
@@ -158,11 +183,16 @@ def webhook_response(
data = json.dumps(data, cls=JSONEncoder)
if registration[ATTR_SUPPORTS_ENCRYPTION]:
keylen, encrypt = setup_encrypt()
keylen, encrypt = setup_encrypt(
HexEncoder if ATTR_NO_LEGACY_ENCRYPTION in registration else RawEncoder
)
key = registration[CONF_SECRET].encode("utf-8")
key = key[:keylen]
key = key.ljust(keylen, b"\0")
if ATTR_NO_LEGACY_ENCRYPTION in registration:
key = registration[CONF_SECRET]
else:
key = registration[CONF_SECRET].encode("utf-8")
key = key[:keylen]
key = key.ljust(keylen, b"\0")
enc_data = encrypt(data.encode("utf-8"), key).decode("utf-8")
data = json.dumps({"encrypted": True, "encrypted_data": enc_data})
+24 -1
View File
@@ -7,6 +7,7 @@ import logging
import secrets
from aiohttp.web import HTTPBadRequest, Request, Response, json_response
from nacl.exceptions import CryptoError
from nacl.secret import SecretBox
import voluptuous as vol
@@ -58,6 +59,7 @@ from .const import (
ATTR_EVENT_TYPE,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_NO_LEGACY_ENCRYPTION,
ATTR_OS_VERSION,
ATTR_SENSOR_ATTRIBUTES,
ATTR_SENSOR_DEVICE_CLASS,
@@ -97,6 +99,7 @@ from .const import (
)
from .helpers import (
_decrypt_payload,
_decrypt_payload_legacy,
empty_okay_response,
error_response,
registration_context,
@@ -191,7 +194,27 @@ async def handle_webhook(
if req_data[ATTR_WEBHOOK_ENCRYPTED]:
enc_data = req_data[ATTR_WEBHOOK_ENCRYPTED_DATA]
webhook_payload = _decrypt_payload(config_entry.data[CONF_SECRET], enc_data)
try:
webhook_payload = _decrypt_payload(config_entry.data[CONF_SECRET], enc_data)
if ATTR_NO_LEGACY_ENCRYPTION not in config_entry.data:
data = {**config_entry.data, ATTR_NO_LEGACY_ENCRYPTION: True}
hass.config_entries.async_update_entry(config_entry, data=data)
except CryptoError:
if ATTR_NO_LEGACY_ENCRYPTION not in config_entry.data:
try:
webhook_payload = _decrypt_payload_legacy(
config_entry.data[CONF_SECRET], enc_data
)
except CryptoError:
_LOGGER.warning(
"Ignoring encrypted payload because unable to decrypt"
)
except ValueError:
_LOGGER.warning("Ignoring invalid encrypted payload")
else:
_LOGGER.warning("Ignoring encrypted payload because unable to decrypt")
except ValueError:
_LOGGER.warning("Ignoring invalid encrypted payload")
if webhook_type not in WEBHOOK_COMMANDS:
_LOGGER.error(
@@ -209,7 +209,7 @@ def duplicate_entity_validator(config: dict) -> dict:
addr += "_" + str(entry[CONF_COMMAND_ON])
if CONF_COMMAND_OFF in entry:
addr += "_" + str(entry[CONF_COMMAND_OFF])
addr += "_" + str(entry[CONF_SLAVE])
addr += "_" + str(entry.get(CONF_SLAVE, 0))
if addr in addresses:
err = f"Modbus {component}/{name} address {addr} is duplicate, second entry not loaded!"
_LOGGER.warning(err)
@@ -44,13 +44,7 @@ async def async_setup_entry(
)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(SERVICE_REJECT_CALL, {}, "async_reject_call")
_LOGGER.warning(
"Calling reject_call service is deprecated and will be removed after 2022.4; "
"A new button entity is now available with the same function "
"and replaces the existing service"
)
class ModemCalleridSensor(SensorEntity):
@@ -94,4 +88,9 @@ class ModemCalleridSensor(SensorEntity):
async def async_reject_call(self) -> None:
"""Reject Incoming Call."""
_LOGGER.warning(
"Calling reject_call service is deprecated and will be removed after 2022.4; "
"A new button entity is now available with the same function "
"and replaces the existing service"
)
await self.api.reject_call(self.device)
@@ -3,17 +3,10 @@
"name": "motionEye",
"documentation": "https://www.home-assistant.io/integrations/motioneye",
"config_flow": true,
"dependencies": [
"http",
"media_source",
"webhook"
],
"requirements": [
"motioneye-client==0.3.12"
],
"codeowners": [
"@dermotduffy"
],
"dependencies": ["http", "webhook"],
"after_dependencies": ["media_source"],
"requirements": ["motioneye-client==0.3.12"],
"codeowners": ["@dermotduffy"],
"iot_class": "local_polling",
"loggers": ["motioneye_client"]
}
+106 -92
View File
@@ -13,7 +13,7 @@ import logging
from operator import attrgetter
import ssl
import time
from typing import Any, Union, cast
from typing import TYPE_CHECKING, Any, Union, cast
import uuid
import attr
@@ -75,11 +75,16 @@ from .const import (
ATTR_TOPIC,
CONF_BIRTH_MESSAGE,
CONF_BROKER,
CONF_CERTIFICATE,
CONF_CLIENT_CERT,
CONF_CLIENT_KEY,
CONF_COMMAND_TOPIC,
CONF_ENCODING,
CONF_QOS,
CONF_RETAIN,
CONF_STATE_TOPIC,
CONF_TLS_INSECURE,
CONF_TLS_VERSION,
CONF_TOPIC,
CONF_WILL_MESSAGE,
DATA_MQTT_CONFIG,
@@ -94,6 +99,7 @@ from .const import (
DOMAIN,
MQTT_CONNECTED,
MQTT_DISCONNECTED,
PROTOCOL_31,
PROTOCOL_311,
)
from .discovery import LAST_DISCOVERY
@@ -107,6 +113,11 @@ from .models import (
)
from .util import _VALID_QOS_SCHEMA, valid_publish_topic, valid_subscribe_topic
if TYPE_CHECKING:
# Only import for paho-mqtt type checking here, imports are done locally
# because integrations should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
_LOGGER = logging.getLogger(__name__)
_SENTINEL = object()
@@ -118,13 +129,6 @@ SERVICE_DUMP = "dump"
CONF_DISCOVERY_PREFIX = "discovery_prefix"
CONF_KEEPALIVE = "keepalive"
CONF_CERTIFICATE = "certificate"
CONF_CLIENT_KEY = "client_key"
CONF_CLIENT_CERT = "client_cert"
CONF_TLS_INSECURE = "tls_insecure"
CONF_TLS_VERSION = "tls_version"
PROTOCOL_31 = "3.1"
DEFAULT_PORT = 1883
DEFAULT_KEEPALIVE = 60
@@ -179,6 +183,40 @@ MQTT_WILL_BIRTH_SCHEMA = vol.Schema(
required=True,
)
CONFIG_SCHEMA_BASE = vol.Schema(
{
vol.Optional(CONF_CLIENT_ID): cv.string,
vol.Optional(CONF_KEEPALIVE, default=DEFAULT_KEEPALIVE): vol.All(
vol.Coerce(int), vol.Range(min=15)
),
vol.Optional(CONF_BROKER): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD): cv.string,
vol.Optional(CONF_CERTIFICATE): vol.Any("auto", cv.isfile),
vol.Inclusive(
CONF_CLIENT_KEY, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG
): cv.isfile,
vol.Inclusive(
CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG
): cv.isfile,
vol.Optional(CONF_TLS_INSECURE): cv.boolean,
vol.Optional(CONF_TLS_VERSION, default=DEFAULT_TLS_PROTOCOL): vol.Any(
"auto", "1.0", "1.1", "1.2"
),
vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.All(
cv.string, vol.In([PROTOCOL_31, PROTOCOL_311])
),
vol.Optional(CONF_WILL_MESSAGE, default=DEFAULT_WILL): MQTT_WILL_BIRTH_SCHEMA,
vol.Optional(CONF_BIRTH_MESSAGE, default=DEFAULT_BIRTH): MQTT_WILL_BIRTH_SCHEMA,
vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean,
# discovery_prefix must be a valid publish topic because if no
# state topic is specified, it will be created with the given prefix.
vol.Optional(
CONF_DISCOVERY_PREFIX, default=DEFAULT_PREFIX
): valid_publish_topic,
}
)
CONFIG_SCHEMA = vol.Schema(
{
@@ -191,44 +229,7 @@ CONFIG_SCHEMA = vol.Schema(
cv.deprecated(CONF_TLS_VERSION), # Deprecated June 2020
cv.deprecated(CONF_USERNAME), # Deprecated in HA Core 2022.3
cv.deprecated(CONF_WILL_MESSAGE), # Deprecated in HA Core 2022.3
vol.Schema(
{
vol.Optional(CONF_CLIENT_ID): cv.string,
vol.Optional(CONF_KEEPALIVE, default=DEFAULT_KEEPALIVE): vol.All(
vol.Coerce(int), vol.Range(min=15)
),
vol.Optional(CONF_BROKER): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD): cv.string,
vol.Optional(CONF_CERTIFICATE): vol.Any("auto", cv.isfile),
vol.Inclusive(
CONF_CLIENT_KEY, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG
): cv.isfile,
vol.Inclusive(
CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG
): cv.isfile,
vol.Optional(CONF_TLS_INSECURE): cv.boolean,
vol.Optional(
CONF_TLS_VERSION, default=DEFAULT_TLS_PROTOCOL
): vol.Any("auto", "1.0", "1.1", "1.2"),
vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.All(
cv.string, vol.In([PROTOCOL_31, PROTOCOL_311])
),
vol.Optional(
CONF_WILL_MESSAGE, default=DEFAULT_WILL
): MQTT_WILL_BIRTH_SCHEMA,
vol.Optional(
CONF_BIRTH_MESSAGE, default=DEFAULT_BIRTH
): MQTT_WILL_BIRTH_SCHEMA,
vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean,
# discovery_prefix must be a valid publish topic because if no
# state topic is specified, it will be created with the given prefix.
vol.Optional(
CONF_DISCOVERY_PREFIX, default=DEFAULT_PREFIX
): valid_publish_topic,
}
),
CONFIG_SCHEMA_BASE,
)
},
extra=vol.ALLOW_EXTRA,
@@ -619,7 +620,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load a config entry."""
# If user didn't have configuration.yaml config, generate defaults
if (conf := hass.data.get(DATA_MQTT_CONFIG)) is None:
conf = CONFIG_SCHEMA({DOMAIN: dict(entry.data)})[DOMAIN]
conf = CONFIG_SCHEMA_BASE(dict(entry.data))
elif any(key in conf for key in entry.data):
shared_keys = conf.keys() & entry.data.keys()
override = {k: entry.data[k] for k in shared_keys}
@@ -760,6 +761,58 @@ class Subscription:
encoding: str | None = attr.ib(default="utf-8")
class MqttClientSetup:
"""Helper class to setup the paho mqtt client from config."""
def __init__(self, config: ConfigType) -> None:
"""Initialize the MQTT client setup helper."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
if config[CONF_PROTOCOL] == PROTOCOL_31:
proto = mqtt.MQTTv31
else:
proto = mqtt.MQTTv311
if (client_id := config.get(CONF_CLIENT_ID)) is None:
# PAHO MQTT relies on the MQTT server to generate random client IDs.
# However, that feature is not mandatory so we generate our own.
client_id = mqtt.base62(uuid.uuid4().int, padding=22)
self._client = mqtt.Client(client_id, protocol=proto)
# Enable logging
self._client.enable_logger()
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
if username is not None:
self._client.username_pw_set(username, password)
if (certificate := config.get(CONF_CERTIFICATE)) == "auto":
certificate = certifi.where()
client_key = config.get(CONF_CLIENT_KEY)
client_cert = config.get(CONF_CLIENT_CERT)
tls_insecure = config.get(CONF_TLS_INSECURE)
if certificate is not None:
self._client.tls_set(
certificate,
certfile=client_cert,
keyfile=client_key,
tls_version=ssl.PROTOCOL_TLS,
)
if tls_insecure is not None:
self._client.tls_insecure_set(tls_insecure)
@property
def client(self) -> mqtt.Client:
"""Return the paho MQTT client."""
return self._client
class MQTT:
"""Home Assistant MQTT client."""
@@ -811,7 +864,7 @@ class MQTT:
self = hass.data[DATA_MQTT]
if (conf := hass.data.get(DATA_MQTT_CONFIG)) is None:
conf = CONFIG_SCHEMA({DOMAIN: dict(entry.data)})[DOMAIN]
conf = CONFIG_SCHEMA_BASE(dict(entry.data))
self.conf = _merge_config(entry, conf)
await self.async_disconnect()
@@ -824,46 +877,7 @@ class MQTT:
def init_client(self):
"""Initialize paho client."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
if self.conf[CONF_PROTOCOL] == PROTOCOL_31:
proto: int = mqtt.MQTTv31
else:
proto = mqtt.MQTTv311
if (client_id := self.conf.get(CONF_CLIENT_ID)) is None:
# PAHO MQTT relies on the MQTT server to generate random client IDs.
# However, that feature is not mandatory so we generate our own.
client_id = mqtt.base62(uuid.uuid4().int, padding=22)
self._mqttc = mqtt.Client(client_id, protocol=proto)
# Enable logging
self._mqttc.enable_logger()
username = self.conf.get(CONF_USERNAME)
password = self.conf.get(CONF_PASSWORD)
if username is not None:
self._mqttc.username_pw_set(username, password)
if (certificate := self.conf.get(CONF_CERTIFICATE)) == "auto":
certificate = certifi.where()
client_key = self.conf.get(CONF_CLIENT_KEY)
client_cert = self.conf.get(CONF_CLIENT_CERT)
tls_insecure = self.conf.get(CONF_TLS_INSECURE)
if certificate is not None:
self._mqttc.tls_set(
certificate,
certfile=client_cert,
keyfile=client_key,
tls_version=ssl.PROTOCOL_TLS,
)
if tls_insecure is not None:
self._mqttc.tls_insecure_set(tls_insecure)
self._mqttc = MqttClientSetup(self.conf).client
self._mqttc.on_connect = self._mqtt_on_connect
self._mqttc.on_disconnect = self._mqtt_on_disconnect
self._mqttc.on_message = self._mqtt_on_message
@@ -970,10 +984,6 @@ class MQTT:
self.subscriptions.remove(subscription)
self._matching_subscriptions.cache_clear()
if any(other.topic == topic for other in self.subscriptions):
# Other subscriptions on topic remaining - don't unsubscribe.
return
# Only unsubscribe if currently connected.
if self.connected:
self.hass.async_create_task(self._async_unsubscribe(topic))
@@ -985,6 +995,10 @@ class MQTT:
This method is a coroutine.
"""
if any(other.topic == topic for other in self.subscriptions):
# Other subscriptions on topic remaining - don't unsubscribe.
return
async with self._paho_lock:
result: int | None = None
result, mid = await self.hass.async_add_executor_job(
+23 -9
View File
@@ -271,7 +271,7 @@ _PLATFORM_SCHEMA_BASE = SCHEMA_BASE.extend(
vol.Optional(CONF_HOLD_COMMAND_TOPIC): mqtt.valid_publish_topic,
vol.Optional(CONF_HOLD_STATE_TEMPLATE): cv.template,
vol.Optional(CONF_HOLD_STATE_TOPIC): mqtt.valid_subscribe_topic,
vol.Optional(CONF_HOLD_LIST, default=list): cv.ensure_list,
vol.Optional(CONF_HOLD_LIST): cv.ensure_list,
vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
vol.Optional(
@@ -298,7 +298,7 @@ _PLATFORM_SCHEMA_BASE = SCHEMA_BASE.extend(
),
vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean,
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean,
vol.Optional(CONF_SEND_IF_OFF): cv.boolean,
vol.Optional(CONF_ACTION_TEMPLATE): cv.template,
vol.Optional(CONF_ACTION_TOPIC): mqtt.valid_subscribe_topic,
# CONF_PRESET_MODE_COMMAND_TOPIC and CONF_PRESET_MODES_LIST must be used together
@@ -431,6 +431,12 @@ class MqttClimate(MqttEntity, ClimateEntity):
self._feature_preset_mode = False
self._optimistic_preset_mode = None
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
self._send_if_off = True
# AWAY and HOLD mode topics and templates are deprecated,
# support will be removed with release 2022.9
self._hold_list = []
MqttEntity.__init__(self, hass, config, config_entry, discovery_data)
@staticmethod
@@ -499,6 +505,15 @@ class MqttClimate(MqttEntity, ClimateEntity):
self._command_templates = command_templates
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
if CONF_SEND_IF_OFF in config:
self._send_if_off = config[CONF_SEND_IF_OFF]
# AWAY and HOLD mode topics and templates are deprecated,
# support will be removed with release 2022.9
if CONF_HOLD_LIST in config:
self._hold_list = config[CONF_HOLD_LIST]
def _prepare_subscribe_topics(self): # noqa: C901
"""(Re)Subscribe to topics."""
topics = {}
@@ -806,7 +821,9 @@ class MqttClimate(MqttEntity, ClimateEntity):
):
presets.append(PRESET_AWAY)
presets.extend(self._config[CONF_HOLD_LIST])
# AWAY and HOLD mode topics and templates are deprecated,
# support will be removed with release 2022.9
presets.extend(self._hold_list)
if presets:
presets.insert(0, PRESET_NONE)
@@ -847,10 +864,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
setattr(self, attr, temp)
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
if (
self._config[CONF_SEND_IF_OFF]
or self._current_operation != HVAC_MODE_OFF
):
if self._send_if_off or self._current_operation != HVAC_MODE_OFF:
payload = self._command_templates[cmnd_template](temp)
await self._publish(cmnd_topic, payload)
@@ -890,7 +904,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
async def async_set_swing_mode(self, swing_mode):
"""Set new swing mode."""
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
if self._config[CONF_SEND_IF_OFF] or self._current_operation != HVAC_MODE_OFF:
if self._send_if_off or self._current_operation != HVAC_MODE_OFF:
payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](
swing_mode
)
@@ -903,7 +917,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
async def async_set_fan_mode(self, fan_mode):
"""Set new target temperature."""
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
if self._config[CONF_SEND_IF_OFF] or self._current_operation != HVAC_MODE_OFF:
if self._send_if_off or self._current_operation != HVAC_MODE_OFF:
payload = self._command_templates[CONF_FAN_MODE_COMMAND_TEMPLATE](fan_mode)
await self._publish(CONF_FAN_MODE_COMMAND_TOPIC, payload)
+18 -11
View File
@@ -17,6 +17,7 @@ from homeassistant.const import (
)
from homeassistant.data_entry_flow import FlowResult
from . import MqttClientSetup
from .const import (
ATTR_PAYLOAD,
ATTR_QOS,
@@ -62,6 +63,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None:
can_connect = await self.hass.async_add_executor_job(
try_connection,
self.hass,
user_input[CONF_BROKER],
user_input[CONF_PORT],
user_input.get(CONF_USERNAME),
@@ -102,6 +104,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
data = self._hassio_discovery
can_connect = await self.hass.async_add_executor_job(
try_connection,
self.hass,
data[CONF_HOST],
data[CONF_PORT],
data.get(CONF_USERNAME),
@@ -152,6 +155,7 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow):
if user_input is not None:
can_connect = await self.hass.async_add_executor_job(
try_connection,
self.hass,
user_input[CONF_BROKER],
user_input[CONF_PORT],
user_input.get(CONF_USERNAME),
@@ -313,19 +317,22 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow):
)
def try_connection(broker, port, username, password, protocol="3.1"):
def try_connection(hass, broker, port, username, password, protocol="3.1"):
"""Test if we can connect to an MQTT broker."""
# pylint: disable-next=import-outside-toplevel
import paho.mqtt.client as mqtt
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
if protocol == "3.1":
proto = mqtt.MQTTv31
else:
proto = mqtt.MQTTv311
client = mqtt.Client(protocol=proto)
if username and password:
client.username_pw_set(username, password)
# Get the config from configuration.yaml
yaml_config = hass.data.get(DATA_MQTT_CONFIG, {})
entry_config = {
CONF_BROKER: broker,
CONF_PORT: port,
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_PROTOCOL: protocol,
}
client = MqttClientSetup({**yaml_config, **entry_config}).client
result = queue.Queue(maxsize=1)
+7
View File
@@ -22,6 +22,12 @@ CONF_STATE_VALUE_TEMPLATE = "state_value_template"
CONF_TOPIC = "topic"
CONF_WILL_MESSAGE = "will_message"
CONF_CERTIFICATE = "certificate"
CONF_CLIENT_KEY = "client_key"
CONF_CLIENT_CERT = "client_cert"
CONF_TLS_INSECURE = "tls_insecure"
CONF_TLS_VERSION = "tls_version"
DATA_MQTT_CONFIG = "mqtt_config"
DATA_MQTT_RELOAD_NEEDED = "mqtt_reload_needed"
@@ -56,4 +62,5 @@ MQTT_DISCONNECTED = "mqtt_disconnected"
PAYLOAD_EMPTY_JSON = "{}"
PAYLOAD_NONE = "None"
PROTOCOL_31 = "3.1"
PROTOCOL_311 = "3.1.1"
+8 -8
View File
@@ -153,11 +153,17 @@ class NanoleafLight(NanoleafEntity, LightEntity):
effect = kwargs.get(ATTR_EFFECT)
transition = kwargs.get(ATTR_TRANSITION)
if hs_color:
if effect:
if effect not in self.effect_list:
raise ValueError(
f"Attempting to apply effect not in the effect list: '{effect}'"
)
await self._nanoleaf.set_effect(effect)
elif hs_color:
hue, saturation = hs_color
await self._nanoleaf.set_hue(int(hue))
await self._nanoleaf.set_saturation(int(saturation))
if color_temp_mired:
elif color_temp_mired:
await self._nanoleaf.set_color_temperature(
mired_to_kelvin(color_temp_mired)
)
@@ -172,12 +178,6 @@ class NanoleafLight(NanoleafEntity, LightEntity):
await self._nanoleaf.turn_on()
if brightness:
await self._nanoleaf.set_brightness(int(brightness / 2.55))
if effect:
if effect not in self.effect_list:
raise ValueError(
f"Attempting to apply effect not in the effect list: '{effect}'"
)
await self._nanoleaf.set_effect(effect)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
+2 -1
View File
@@ -2,7 +2,8 @@
"domain": "nest",
"name": "Nest",
"config_flow": true,
"dependencies": ["ffmpeg", "http", "media_source"],
"dependencies": ["ffmpeg", "http"],
"after_dependencies": ["media_source"],
"documentation": "https://www.home-assistant.io/integrations/nest",
"requirements": ["python-nest==4.2.0", "google-nest-sdm==1.7.1"],
"codeowners": ["@allenporter"],
@@ -2,7 +2,7 @@
"domain": "obihai",
"name": "Obihai",
"documentation": "https://www.home-assistant.io/integrations/obihai",
"requirements": ["pyobihai==1.3.1"],
"requirements": ["pyobihai==1.3.2"],
"codeowners": ["@dshokouhi"],
"iot_class": "local_polling",
"loggers": ["pyobihai"]
@@ -4,7 +4,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/overkiz",
"requirements": [
"pyoverkiz==1.3.8"
"pyoverkiz==1.3.9"
],
"zeroconf": [
{
@@ -112,7 +112,7 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator):
next_delivery = (
copy.deepcopy(next_deliveries[-1]) if next_deliveries else {}
)
last_order = copy.deepcopy(deliveries[0])
last_order = copy.deepcopy(deliveries[0]) if deliveries else {}
except (KeyError, TypeError):
# A KeyError or TypeError indicate that the response contains unexpected data
return {}, {}
+1 -1
View File
@@ -4,7 +4,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/plex",
"requirements": [
"plexapi==4.9.2",
"plexapi==4.10.0",
"plexauth==0.0.6",
"plexwebsocket==0.0.13"
],
@@ -110,6 +110,15 @@ class PowerWallChargingStatusSensor(PowerWallEntity, BinarySensorEntity):
_attr_name = "Powerwall Charging"
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
@property
def available(self) -> bool:
"""Powerwall is available."""
# Return False if no battery is installed
return (
super().available
and self.data.meters.get_meter(MeterType.BATTERY) is not None
)
@property
def unique_id(self) -> str:
"""Device Uniqueid."""
+62 -27
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any
from tesla_powerwall import MeterType
from tesla_powerwall import Meter, MeterType
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -28,7 +28,6 @@ from .models import PowerwallData, PowerwallRuntimeData
_METER_DIRECTION_EXPORT = "export"
_METER_DIRECTION_IMPORT = "import"
_METER_DIRECTIONS = [_METER_DIRECTION_EXPORT, _METER_DIRECTION_IMPORT]
async def async_setup_entry(
@@ -42,20 +41,20 @@ async def async_setup_entry(
assert coordinator is not None
data: PowerwallData = coordinator.data
entities: list[
PowerWallEnergySensor | PowerWallEnergyDirectionSensor | PowerWallChargeSensor
] = []
for meter in data.meters.meters:
entities.append(PowerWallEnergySensor(powerwall_data, meter))
for meter_direction in _METER_DIRECTIONS:
entities.append(
PowerWallEnergyDirectionSensor(
powerwall_data,
meter,
meter_direction,
)
)
PowerWallEnergySensor
| PowerWallImportSensor
| PowerWallExportSensor
| PowerWallChargeSensor
] = [PowerWallChargeSensor(powerwall_data)]
entities.append(PowerWallChargeSensor(powerwall_data))
for meter in data.meters.meters:
entities.extend(
[
PowerWallEnergySensor(powerwall_data, meter),
PowerWallExportSensor(powerwall_data, meter),
PowerWallImportSensor(powerwall_data, meter),
]
)
async_add_entities(entities)
@@ -115,7 +114,7 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity):
class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity):
"""Representation of an Powerwall Direction Energy sensor."""
_attr_state_class = SensorStateClass.TOTAL_INCREASING
_attr_state_class = SensorStateClass.TOTAL
_attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR
_attr_device_class = SensorDeviceClass.ENERGY
@@ -128,18 +127,54 @@ class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity):
"""Initialize the sensor."""
super().__init__(powerwall_data)
self._meter = meter
self._meter_direction = meter_direction
self._attr_name = (
f"Powerwall {self._meter.value.title()} {self._meter_direction.title()}"
)
self._attr_unique_id = (
f"{self.base_unique_id}_{self._meter.value}_{self._meter_direction}"
)
self._attr_name = f"Powerwall {meter.value.title()} {meter_direction.title()}"
self._attr_unique_id = f"{self.base_unique_id}_{meter.value}_{meter_direction}"
@property
def available(self) -> bool:
"""Check if the reading is actually available.
The device reports 0 when something goes wrong which
we do not want to include in statistics and its a
transient data error.
"""
return super().available and self.native_value != 0
@property
def meter(self) -> Meter:
"""Get the meter for the sensor."""
return self.data.meters.get_meter(self._meter)
class PowerWallExportSensor(PowerWallEnergyDirectionSensor):
"""Representation of an Powerwall Export sensor."""
def __init__(
self,
powerwall_data: PowerwallRuntimeData,
meter: MeterType,
) -> None:
"""Initialize the sensor."""
super().__init__(powerwall_data, meter, _METER_DIRECTION_EXPORT)
@property
def native_value(self) -> float:
"""Get the current value in kWh."""
meter = self.data.meters.get_meter(self._meter)
if self._meter_direction == _METER_DIRECTION_EXPORT:
return meter.get_energy_exported()
return meter.get_energy_imported()
return self.meter.get_energy_exported()
class PowerWallImportSensor(PowerWallEnergyDirectionSensor):
"""Representation of an Powerwall Import sensor."""
def __init__(
self,
powerwall_data: PowerwallRuntimeData,
meter: MeterType,
) -> None:
"""Initialize the sensor."""
super().__init__(powerwall_data, meter, _METER_DIRECTION_IMPORT)
@property
def native_value(self) -> float:
"""Get the current value in kWh."""
return self.meter.get_energy_imported()
@@ -3,7 +3,7 @@
"name": "Radio Browser",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/radio",
"requirements": ["radios==0.1.0"],
"requirements": ["radios==0.1.1"],
"codeowners": ["@frenck"],
"iot_class": "cloud_polling"
}
@@ -12,6 +12,7 @@ from homeassistant.components.media_player.const import (
MEDIA_TYPE_MUSIC,
)
from homeassistant.components.media_player.errors import BrowseError
from homeassistant.components.media_source.error import Unresolvable
from homeassistant.components.media_source.models import (
BrowseMediaSource,
MediaSource,
@@ -35,9 +36,8 @@ async def async_get_media_source(hass: HomeAssistant) -> RadioMediaSource:
"""Set up Radio Browser media source."""
# Radio browser support only a single config entry
entry = hass.config_entries.async_entries(DOMAIN)[0]
radios = hass.data[DOMAIN]
return RadioMediaSource(hass, radios, entry)
return RadioMediaSource(hass, entry)
class RadioMediaSource(MediaSource):
@@ -45,26 +45,33 @@ class RadioMediaSource(MediaSource):
name = "Radio Browser"
def __init__(
self, hass: HomeAssistant, radios: RadioBrowser, entry: ConfigEntry
) -> None:
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize CameraMediaSource."""
super().__init__(DOMAIN)
self.hass = hass
self.entry = entry
self.radios = radios
@property
def radios(self) -> RadioBrowser | None:
"""Return the radio browser."""
return self.hass.data.get(DOMAIN)
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve selected Radio station to a streaming URL."""
station = await self.radios.station(uuid=item.identifier)
radios = self.radios
if radios is None:
raise Unresolvable("Radio Browser not initialized")
station = await radios.station(uuid=item.identifier)
if not station:
raise BrowseError("Radio station is no longer available")
raise Unresolvable("Radio station is no longer available")
if not (mime_type := self._async_get_station_mime_type(station)):
raise BrowseError("Could not determine stream type of radio station")
raise Unresolvable("Could not determine stream type of radio station")
# Register "click" with Radio Browser
await self.radios.station_click(uuid=station.uuid)
await radios.station_click(uuid=station.uuid)
return PlayMedia(station.url, mime_type)
@@ -73,6 +80,11 @@ class RadioMediaSource(MediaSource):
item: MediaSourceItem,
) -> BrowseMediaSource:
"""Return media."""
radios = self.radios
if radios is None:
raise BrowseError("Radio Browser not initialized")
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
@@ -83,10 +95,10 @@ class RadioMediaSource(MediaSource):
can_expand=True,
children_media_class=MEDIA_CLASS_DIRECTORY,
children=[
*await self._async_build_popular(item),
*await self._async_build_by_tag(item),
*await self._async_build_by_language(item),
*await self._async_build_by_country(item),
*await self._async_build_popular(radios, item),
*await self._async_build_by_tag(radios, item),
*await self._async_build_by_language(radios, item),
*await self._async_build_by_country(radios, item),
],
)
@@ -100,7 +112,9 @@ class RadioMediaSource(MediaSource):
return mime_type
@callback
def _async_build_stations(self, stations: list[Station]) -> list[BrowseMediaSource]:
def _async_build_stations(
self, radios: RadioBrowser, stations: list[Station]
) -> list[BrowseMediaSource]:
"""Build list of media sources from radio stations."""
items: list[BrowseMediaSource] = []
@@ -126,23 +140,23 @@ class RadioMediaSource(MediaSource):
return items
async def _async_build_by_country(
self, item: MediaSourceItem
self, radios: RadioBrowser, item: MediaSourceItem
) -> list[BrowseMediaSource]:
"""Handle browsing radio stations by country."""
category, _, country_code = (item.identifier or "").partition("/")
if country_code:
stations = await self.radios.stations(
stations = await radios.stations(
filter_by=FilterBy.COUNTRY_CODE_EXACT,
filter_term=country_code,
hide_broken=True,
order=Order.NAME,
reverse=False,
)
return self._async_build_stations(stations)
return self._async_build_stations(radios, stations)
# We show country in the root additionally, when there is no item
if not item.identifier or category == "country":
countries = await self.radios.countries(order=Order.NAME)
countries = await radios.countries(order=Order.NAME)
return [
BrowseMediaSource(
domain=DOMAIN,
@@ -160,22 +174,22 @@ class RadioMediaSource(MediaSource):
return []
async def _async_build_by_language(
self, item: MediaSourceItem
self, radios: RadioBrowser, item: MediaSourceItem
) -> list[BrowseMediaSource]:
"""Handle browsing radio stations by language."""
category, _, language = (item.identifier or "").partition("/")
if category == "language" and language:
stations = await self.radios.stations(
stations = await radios.stations(
filter_by=FilterBy.LANGUAGE_EXACT,
filter_term=language,
hide_broken=True,
order=Order.NAME,
reverse=False,
)
return self._async_build_stations(stations)
return self._async_build_stations(radios, stations)
if category == "language":
languages = await self.radios.languages(order=Order.NAME, hide_broken=True)
languages = await radios.languages(order=Order.NAME, hide_broken=True)
return [
BrowseMediaSource(
domain=DOMAIN,
@@ -206,17 +220,17 @@ class RadioMediaSource(MediaSource):
return []
async def _async_build_popular(
self, item: MediaSourceItem
self, radios: RadioBrowser, item: MediaSourceItem
) -> list[BrowseMediaSource]:
"""Handle browsing popular radio stations."""
if item.identifier == "popular":
stations = await self.radios.stations(
stations = await radios.stations(
hide_broken=True,
limit=250,
order=Order.CLICK_COUNT,
reverse=True,
)
return self._async_build_stations(stations)
return self._async_build_stations(radios, stations)
if not item.identifier:
return [
@@ -234,22 +248,22 @@ class RadioMediaSource(MediaSource):
return []
async def _async_build_by_tag(
self, item: MediaSourceItem
self, radios: RadioBrowser, item: MediaSourceItem
) -> list[BrowseMediaSource]:
"""Handle browsing radio stations by tags."""
category, _, tag = (item.identifier or "").partition("/")
if category == "tag" and tag:
stations = await self.radios.stations(
stations = await radios.stations(
filter_by=FilterBy.TAG_EXACT,
filter_term=tag,
hide_broken=True,
order=Order.NAME,
reverse=False,
)
return self._async_build_stations(stations)
return self._async_build_stations(radios, stations)
if category == "tag":
tags = await self.radios.tags(
tags = await radios.tags(
hide_broken=True,
limit=100,
order=Order.STATION_COUNT,
@@ -47,7 +47,7 @@ send_command:
required: true
example: "Play"
selector:
text:
object:
num_repeats:
name: Repeats
description: The number of times you want to repeat the command(s).
@@ -104,7 +104,7 @@ class RenaultVehicleProxy:
coordinator = self.coordinators[key]
if coordinator.not_supported:
# Remove endpoint as it is not supported for this vehicle.
LOGGER.warning(
LOGGER.info(
"Ignoring endpoint %s as it is not supported for this vehicle: %s",
coordinator.name,
coordinator.last_exception,
@@ -112,7 +112,7 @@ class RenaultVehicleProxy:
del self.coordinators[key]
elif coordinator.access_denied:
# Remove endpoint as it is denied for this vehicle.
LOGGER.warning(
LOGGER.info(
"Ignoring endpoint %s as it is denied for this vehicle: %s",
coordinator.name,
coordinator.last_exception,
+26 -7
View File
@@ -6,7 +6,7 @@ import binascii
from collections.abc import Callable
import copy
import logging
from typing import NamedTuple
from typing import NamedTuple, cast
import RFXtrx as rfxtrxmod
import async_timeout
@@ -229,11 +229,7 @@ async def async_setup_internal(hass, entry: config_entries.ConfigEntry):
devices[device_id] = config
@callback
def _remove_device(event: Event):
if event.data["action"] != "remove":
return
device_entry = device_registry.deleted_devices[event.data["device_id"]]
device_id = next(iter(device_entry.identifiers))[1:]
def _remove_device(device_id: DeviceTuple):
data = {
**entry.data,
CONF_DEVICES: {
@@ -245,8 +241,19 @@ async def async_setup_internal(hass, entry: config_entries.ConfigEntry):
hass.config_entries.async_update_entry(entry=entry, data=data)
devices.pop(device_id)
@callback
def _updated_device(event: Event):
if event.data["action"] != "remove":
return
device_entry = device_registry.deleted_devices[event.data["device_id"]]
if entry.entry_id not in device_entry.config_entries:
return
device_id = get_device_tuple_from_identifiers(device_entry.identifiers)
if device_id:
_remove_device(device_id)
entry.async_on_unload(
hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, _remove_device)
hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, _updated_device)
)
def _shutdown_rfxtrx(event):
@@ -413,6 +420,18 @@ def get_device_id(
return DeviceTuple(f"{device.packettype:x}", f"{device.subtype:x}", id_string)
def get_device_tuple_from_identifiers(
identifiers: set[tuple[str, str]]
) -> DeviceTuple | None:
"""Calculate the device tuple from a device entry."""
identifier = next((x for x in identifiers if x[0] == DOMAIN), None)
if not identifier:
return None
# work around legacy identifier, being a multi tuple value
identifier2 = cast(tuple[str, str, str, str], identifier)
return DeviceTuple(identifier2[1], identifier2[2], identifier2[3])
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
) -> bool:
+11 -5
View File
@@ -34,7 +34,13 @@ from homeassistant.helpers.entity_registry import (
async_get_registry as async_get_entity_registry,
)
from . import DOMAIN, DeviceTuple, get_device_id, get_rfx_object
from . import (
DOMAIN,
DeviceTuple,
get_device_id,
get_device_tuple_from_identifiers,
get_rfx_object,
)
from .binary_sensor import supported as binary_supported
from .const import (
CONF_AUTOMATIC_ADD,
@@ -59,7 +65,7 @@ CONF_MANUAL_PATH = "Enter Manually"
class DeviceData(TypedDict):
"""Dict data representing a device entry."""
event_code: str
event_code: str | None
device_id: DeviceTuple
@@ -388,15 +394,15 @@ class OptionsFlow(config_entries.OptionsFlow):
def _get_device_data(self, entry_id) -> DeviceData:
"""Get event code based on device identifier."""
event_code: str
event_code: str | None = None
entry = self._device_registry.async_get(entry_id)
assert entry
device_id = cast(DeviceTuple, next(iter(entry.identifiers))[1:])
device_id = get_device_tuple_from_identifiers(entry.identifiers)
assert device_id
for packet_id, entity_info in self._config_entry.data[CONF_DEVICES].items():
if tuple(entity_info.get(CONF_DEVICE_ID)) == device_id:
event_code = cast(str, packet_id)
break
assert event_code
return DeviceData(event_code=event_code, device_id=device_id)
@callback
@@ -2,7 +2,7 @@
"domain": "rfxtrx",
"name": "RFXCOM RFXtrx",
"documentation": "https://www.home-assistant.io/integrations/rfxtrx",
"requirements": ["pyRFXtrx==0.27.1"],
"requirements": ["pyRFXtrx==0.28.0"],
"codeowners": ["@danielhiversen", "@elupus", "@RobBie1221"],
"config_flow": true,
"iot_class": "local_push",
-32
View File
@@ -1,14 +1,6 @@
"""Support for Roku."""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
import logging
from typing import Any, TypeVar
from rokuecp import RokuConnectionError, RokuError
from typing_extensions import Concatenate, ParamSpec
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
@@ -16,7 +8,6 @@ from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from .coordinator import RokuDataUpdateCoordinator
from .entity import RokuEntity
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
@@ -27,10 +18,6 @@ PLATFORMS = [
Platform.SELECT,
Platform.SENSOR,
]
_LOGGER = logging.getLogger(__name__)
_T = TypeVar("_T", bound="RokuEntity")
_P = ParamSpec("_P")
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -53,22 +40,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
def roku_exception_handler(
func: Callable[Concatenate[_T, _P], Awaitable[None]] # type: ignore[misc]
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: # type: ignore[misc]
"""Decorate Roku calls to handle Roku exceptions."""
@wraps(func)
async def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(self, *args, **kwargs)
except RokuConnectionError as error:
if self.available:
_LOGGER.error("Error communicating with API: %s", error)
except RokuError as error:
if self.available:
_LOGGER.error("Invalid response from API: %s", error)
return wrapper
@@ -135,6 +135,9 @@ async def root_payload(
)
)
for child in children:
child.thumbnail = "https://brands.home-assistant.io/_/roku/logo.png"
try:
browse_item = await media_source.async_browse_media(hass, None)
+40
View File
@@ -1,6 +1,21 @@
"""Helpers for Roku."""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
import logging
from typing import Any, TypeVar
from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError
from typing_extensions import Concatenate, ParamSpec
from .entity import RokuEntity
_LOGGER = logging.getLogger(__name__)
_T = TypeVar("_T", bound=RokuEntity)
_P = ParamSpec("_P")
def format_channel_name(channel_number: str, channel_name: str | None = None) -> str:
"""Format a Roku Channel name."""
@@ -8,3 +23,28 @@ def format_channel_name(channel_number: str, channel_name: str | None = None) ->
return f"{channel_name} ({channel_number})"
return channel_number
def roku_exception_handler(ignore_timeout: bool = False) -> Callable[..., Callable]:
"""Decorate Roku calls to handle Roku exceptions."""
def decorator(
func: Callable[Concatenate[_T, _P], Awaitable[None]], # type: ignore[misc]
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: # type: ignore[misc]
@wraps(func)
async def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(self, *args, **kwargs)
except RokuConnectionTimeoutError as error:
if not ignore_timeout and self.available:
_LOGGER.error("Error communicating with API: %s", error)
except RokuConnectionError as error:
if self.available:
_LOGGER.error("Error communicating with API: %s", error)
except RokuError as error:
if self.available:
_LOGGER.error("Invalid response from API: %s", error)
return wrapper
return decorator
+1 -1
View File
@@ -2,7 +2,7 @@
"domain": "roku",
"name": "Roku",
"documentation": "https://www.home-assistant.io/integrations/roku",
"requirements": ["rokuecp==0.14.1"],
"requirements": ["rokuecp==0.15.0"],
"homekit": {
"models": ["3810X", "4660X", "7820X", "C105X", "C135X"]
},
+14 -15
View File
@@ -51,7 +51,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import roku_exception_handler
from .browse_media import async_browse_media
from .const import (
ATTR_ARTIST_NAME,
@@ -65,7 +64,7 @@ from .const import (
)
from .coordinator import RokuDataUpdateCoordinator
from .entity import RokuEntity
from .helpers import format_channel_name
from .helpers import format_channel_name, roku_exception_handler
_LOGGER = logging.getLogger(__name__)
@@ -289,7 +288,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
app.name for app in self.coordinator.data.apps if app.name is not None
)
@roku_exception_handler
@roku_exception_handler()
async def search(self, keyword: str) -> None:
"""Emulate opening the search screen and entering the search keyword."""
await self.coordinator.roku.search(keyword)
@@ -321,68 +320,68 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
media_content_type,
)
@roku_exception_handler
@roku_exception_handler()
async def async_turn_on(self) -> None:
"""Turn on the Roku."""
await self.coordinator.roku.remote("poweron")
await self.coordinator.async_request_refresh()
@roku_exception_handler
@roku_exception_handler(ignore_timeout=True)
async def async_turn_off(self) -> None:
"""Turn off the Roku."""
await self.coordinator.roku.remote("poweroff")
await self.coordinator.async_request_refresh()
@roku_exception_handler
@roku_exception_handler()
async def async_media_pause(self) -> None:
"""Send pause command."""
if self.state not in (STATE_STANDBY, STATE_PAUSED):
await self.coordinator.roku.remote("play")
await self.coordinator.async_request_refresh()
@roku_exception_handler
@roku_exception_handler()
async def async_media_play(self) -> None:
"""Send play command."""
if self.state not in (STATE_STANDBY, STATE_PLAYING):
await self.coordinator.roku.remote("play")
await self.coordinator.async_request_refresh()
@roku_exception_handler
@roku_exception_handler()
async def async_media_play_pause(self) -> None:
"""Send play/pause command."""
if self.state != STATE_STANDBY:
await self.coordinator.roku.remote("play")
await self.coordinator.async_request_refresh()
@roku_exception_handler
@roku_exception_handler()
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
await self.coordinator.roku.remote("reverse")
await self.coordinator.async_request_refresh()
@roku_exception_handler
@roku_exception_handler()
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self.coordinator.roku.remote("forward")
await self.coordinator.async_request_refresh()
@roku_exception_handler
@roku_exception_handler()
async def async_mute_volume(self, mute: bool) -> None:
"""Mute the volume."""
await self.coordinator.roku.remote("volume_mute")
await self.coordinator.async_request_refresh()
@roku_exception_handler
@roku_exception_handler()
async def async_volume_up(self) -> None:
"""Volume up media player."""
await self.coordinator.roku.remote("volume_up")
@roku_exception_handler
@roku_exception_handler()
async def async_volume_down(self) -> None:
"""Volume down media player."""
await self.coordinator.roku.remote("volume_down")
@roku_exception_handler
@roku_exception_handler()
async def async_play_media(
self, media_type: str, media_id: str, **kwargs: Any
) -> None:
@@ -487,7 +486,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
await self.coordinator.async_request_refresh()
@roku_exception_handler
@roku_exception_handler()
async def async_select_source(self, source: str) -> None:
"""Select input source."""
if source == "Home":
+4 -4
View File
@@ -9,10 +9,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import roku_exception_handler
from .const import DOMAIN
from .coordinator import RokuDataUpdateCoordinator
from .entity import RokuEntity
from .helpers import roku_exception_handler
async def async_setup_entry(
@@ -44,19 +44,19 @@ class RokuRemote(RokuEntity, RemoteEntity):
"""Return true if device is on."""
return not self.coordinator.data.state.standby
@roku_exception_handler
@roku_exception_handler()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
await self.coordinator.roku.remote("poweron")
await self.coordinator.async_request_refresh()
@roku_exception_handler
@roku_exception_handler(ignore_timeout=True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
await self.coordinator.roku.remote("poweroff")
await self.coordinator.async_request_refresh()
@roku_exception_handler
@roku_exception_handler()
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send a command to one device."""
num_repeats = kwargs[ATTR_NUM_REPEATS]
+2 -3
View File
@@ -12,11 +12,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import roku_exception_handler
from .const import DOMAIN
from .coordinator import RokuDataUpdateCoordinator
from .entity import RokuEntity
from .helpers import format_channel_name
from .helpers import format_channel_name, roku_exception_handler
@dataclass
@@ -163,7 +162,7 @@ class RokuSelectEntity(RokuEntity, SelectEntity):
"""Return a set of selectable options."""
return self.entity_description.options_fn(self.coordinator.data)
@roku_exception_handler
@roku_exception_handler()
async def async_select_option(self, option: str) -> None:
"""Set the option."""
await self.entity_description.set_fn(
@@ -2,7 +2,7 @@
"domain": "sabnzbd",
"name": "SABnzbd",
"documentation": "https://www.home-assistant.io/integrations/sabnzbd",
"requirements": ["pysabnzbd==1.1.0"],
"requirements": ["pysabnzbd==1.1.1"],
"dependencies": ["configurator"],
"after_dependencies": ["discovery"],
"codeowners": [],
+6 -2
View File
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components.light import ATTR_TRANSITION
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON
from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON, STATE_UNAVAILABLE
from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
@@ -117,7 +117,11 @@ class Scene(RestoreEntity):
"""Call when the scene is added to hass."""
await super().async_internal_added_to_hass()
state = await self.async_get_last_state()
if state is not None and state.state is not None:
if (
state is not None
and state.state is not None
and state.state != STATE_UNAVAILABLE
):
self.__last_activated = state.state
def activate(self, **kwargs: Any) -> None:
+1 -1
View File
@@ -175,7 +175,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Call a service to reload scripts."""
if (conf := await component.async_prepare_reload()) is None:
return
async_get_blueprints(hass).async_reset_cache()
await _async_process_config(hass, conf, component)
async def turn_on_service(service: ServiceCall) -> None:
@@ -15,6 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT
MAX_POSSIBLE_STEP = 1000
class SensiboDataUpdateCoordinator(DataUpdateCoordinator):
"""A Sensibo Data Update Coordinator."""
@@ -74,7 +76,11 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator):
.get("values", [0, 1])
)
if temperatures_list:
temperature_step = temperatures_list[1] - temperatures_list[0]
diff = MAX_POSSIBLE_STEP
for i in range(len(temperatures_list) - 1):
if temperatures_list[i + 1] - temperatures_list[i] < diff:
diff = temperatures_list[i + 1] - temperatures_list[i]
temperature_step = diff
active_features = list(ac_states)
full_features = set()
@@ -317,4 +317,14 @@ class BlockSleepingClimate(
if self.device_block and self.block:
_LOGGER.debug("Entity %s attached to blocks", self.name)
assert self.block.channel
self._preset_modes = [
PRESET_NONE,
*self.wrapper.device.settings["thermostats"][int(self.block.channel)][
"schedule_profile_names"
],
]
self.async_write_ha_state()
+1 -1
View File
@@ -336,7 +336,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity):
ATTR_RGBW_COLOR
]
if ATTR_EFFECT in kwargs:
if ATTR_EFFECT in kwargs and ATTR_COLOR_TEMP not in kwargs:
# Color effect change - used only in color mode, switch device mode to color
set_mode = "color"
if self.wrapper.model == "SHBLB-1":
@@ -3,7 +3,7 @@
"name": "Shelly",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/shelly",
"requirements": ["aioshelly==1.0.10"],
"requirements": ["aioshelly==1.0.11"],
"zeroconf": [
{
"type": "_http._tcp.local.",
@@ -215,6 +215,15 @@ SENSORS: Final = {
icon="mdi:gauge",
state_class=SensorStateClass.MEASUREMENT,
),
("sensor", "temp"): BlockSensorDescription(
key="sensor|temp",
name="Temperature",
unit_fn=temperature_unit,
value=lambda value: round(value, 1),
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
("sensor", "extTemp"): BlockSensorDescription(
key="sensor|extTemp",
name="Temperature",
@@ -3,7 +3,7 @@
"name": "SleepIQ",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sleepiq",
"requirements": ["asyncsleepiq==1.0.0"],
"requirements": ["asyncsleepiq==1.1.0"],
"codeowners": ["@mfugate1", "@kbickar"],
"dhcp": [
{
@@ -3,7 +3,7 @@
"name": "Sonarr",
"documentation": "https://www.home-assistant.io/integrations/sonarr",
"codeowners": ["@ctalkington"],
"requirements": ["aiopyarr==22.2.1"],
"requirements": ["aiopyarr==22.2.2"],
"config_flow": true,
"quality_scale": "silver",
"iot_class": "local_polling",
@@ -3,7 +3,7 @@
"name": "Sony Songpal",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/songpal",
"requirements": ["python-songpal==0.14"],
"requirements": ["python-songpal==0.14.1"],
"codeowners": ["@rytilahti", "@shenxn"],
"ssdp": [
{
+5 -4
View File
@@ -182,6 +182,9 @@ class SonosDiscoveryManager:
soco = SoCo(ip_address)
# Ensure that the player is available and UID is cached
uid = soco.uid
# Abort early if the device is not visible
if not soco.is_visible:
return None
_ = soco.volume
return soco
except NotSupportedException as exc:
@@ -240,8 +243,7 @@ class SonosDiscoveryManager:
None,
)
if not known_uid:
soco = self._create_soco(ip_addr, SoCoCreationSource.CONFIGURED)
if soco and soco.is_visible:
if soco := self._create_soco(ip_addr, SoCoCreationSource.CONFIGURED):
self._discovered_player(soco)
self.data.hosts_heartbeat = call_later(
@@ -249,8 +251,7 @@ class SonosDiscoveryManager:
)
def _discovered_ip(self, ip_address):
soco = self._create_soco(ip_address, SoCoCreationSource.DISCOVERED)
if soco and soco.is_visible:
if soco := self._create_soco(ip_address, SoCoCreationSource.DISCOVERED):
self._discovered_player(soco)
async def _async_create_discovered_player(self, uid, discovered_ip, boot_seqnum):
+22 -10
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Callable
import logging
from typing import TYPE_CHECKING, TypeVar
from typing import TYPE_CHECKING, Any, TypeVar
from soco import SoCo
from soco.exceptions import SoCoException, SoCoUPnPException
@@ -55,15 +55,9 @@ def soco_error(
)
return None
# In order of preference:
# * SonosSpeaker instance
# * SoCo instance passed as an arg
# * SoCo instance (as self)
speaker_or_soco = getattr(self, "speaker", args_soco or self)
zone_name = speaker_or_soco.zone_name
# Prefer the entity_id if available, zone name as a fallback
# Needed as SonosSpeaker instances are not entities
target = getattr(self, "entity_id", zone_name)
if (target := _find_target_identifier(self, args_soco)) is None:
raise RuntimeError("Unexpected use of soco_error") from err
message = f"Error calling {function} on {target}: {err}"
raise SonosUpdateError(message) from err
@@ -80,6 +74,24 @@ def soco_error(
return decorator
def _find_target_identifier(instance: Any, fallback_soco: SoCo | None) -> str | None:
"""Extract the the best available target identifier from the provided instance object."""
if entity_id := getattr(instance, "entity_id", None):
# SonosEntity instance
return entity_id
if zone_name := getattr(instance, "zone_name", None):
# SonosSpeaker instance
return zone_name
if speaker := getattr(instance, "speaker", None):
# Holds a SonosSpeaker instance attribute
return speaker.zone_name
if soco := getattr(instance, "soco", fallback_soco):
# Holds a SoCo instance attribute
# Only use attributes with no I/O
return soco._player_name or soco.ip_address # pylint: disable=protected-access
return None
def hostname_to_uid(hostname: str) -> str:
"""Convert a Sonos hostname to a uid."""
if hostname.startswith("Sonos-"):
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "Sonos",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sonos",
"requirements": ["soco==0.26.3"],
"requirements": ["soco==0.26.4"],
"dependencies": ["ssdp"],
"after_dependencies": ["plex", "spotify", "zeroconf", "media_source"],
"zeroconf": ["_sonos._tcp.local."],

Some files were not shown because too many files have changed in this diff Show More