Compare commits

..

172 Commits

Author SHA1 Message Date
Franck Nijhof 1b39abe3bc Bumped version to 2023.5.0b2 2023-04-28 21:42:27 +02:00
J. Nick Koston 29bff59707 Fix missing preset_mode feature in bond fans (#92202) 2023-04-28 21:42:18 +02:00
Jean-François Roy faa8f38fa8 Add missing PRESET_MODE feature to BAF fans (#92200) 2023-04-28 21:42:15 +02:00
Paul Bottein 1f6dbe96f6 Update frontend to 20230428.0 (#92190) 2023-04-28 21:42:12 +02:00
Jan Bouwhuis 98075da069 Fix mqtt subscribe debouncer initial delay too long when birth message is disabled (#92188)
Fix mqtt subscribe deboucer initial delay
2023-04-28 21:42:08 +02:00
David F. Mulcahey 652bb8ef95 Fix ZHA device triggers (#92186)
* Fix missing endpoint data on ZHA events

* revert to flat structure

* update test
2023-04-28 21:42:05 +02:00
Nolan Gilley 96d2b53798 Upgrade lakeside to 0.13 (#92173) 2023-04-28 21:42:01 +02:00
Raman Gupta 25d621ab94 Bump pyvizio to 0.1.61 (#92161) 2023-04-28 21:41:58 +02:00
Erik Montnemery fa3f19e7bf Keep expose setting in sync for assist (#92158)
* Keep expose setting in sync for assist

* Fix initialization, add test

* Fix tests

* Add AgentManager.async_setup

* Fix typo

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2023-04-28 21:41:53 +02:00
jjlawren 412ea937ff Properly resolve media_source URLs for Sonos announcements (#92154)
Properly resolve media_source URLs for Sonos announcements
2023-04-28 21:41:46 +02:00
Luke b7f5c144a8 Bump Roborock to 0.8.3 (#92151) 2023-04-28 21:41:42 +02:00
J. Nick Koston 658128c892 Fix ignored apple tvs being scanned over and over (#92150) 2023-04-28 21:41:38 +02:00
J. Nick Koston ff2f6029ce Ensure purge can cleanup old format detached states in the database (#92145) 2023-04-28 21:41:35 +02:00
puddly 8017a04efe Fix ZHA startup failure with the Konke button (#92144)
* Ensure devices with bad cluster subclasses do not prevent startup

* Explicitly unit test an affected SML001 device

* Do not use invalid `hue_occupancy` attribute name

* Actually remove `hue_occupancy`

* Bump ZHA dependencies
2023-04-28 21:41:31 +02:00
G Johansson ef350949fd Fix options flow Workday (#92140)
* Fix options flow workday

* simpler
2023-04-28 21:41:26 +02:00
Luke 7b1b3970b1 Bump roborock to 0.8.1 for beta fixes (#92131)
* bump to 0.8.1

* add tests for new config flow errors

* removed logs for known errors
2023-04-28 21:40:35 +02:00
Franck Nijhof e03f3c05b3 Bumped version to 2023.5.0b1 2023-04-27 19:59:11 +02:00
Franck Nijhof 3e8e2c68b9 Add add-on discovery URL and title to Wyoming integration (#92129) 2023-04-27 19:58:56 +02:00
J. Nick Koston 54e52182ab Bump sqlalchemy to 2.0.11 to fix a critical regression with postgresql (#92126) 2023-04-27 19:58:53 +02:00
Paul Bottein c35872531f Update frontend to 20230427.0 (#92123) 2023-04-27 19:58:49 +02:00
Erik Montnemery 7d5c90a81e Add WS command cloud/alexa/entities/get (#92121)
* Add WS command cloud/alexa/entities/get

* Fix bugs, add test
2023-04-27 19:58:46 +02:00
Thijs W 1f52b71477 Fix frontier_silicon not retrying setup and missing strings (#92111)
Address late review comments for frontier_silicon config flow
2023-04-27 19:58:43 +02:00
Erik Montnemery 9a7f7ef35c Avoid exposing unsupported entities to Alexa (#92107)
* Avoid exposing unsupported entities to Alexa

* Update homeassistant/components/cloud/alexa_config.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

---------

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2023-04-27 19:58:39 +02:00
Erik Montnemery a41128dae3 Avoid exposing unsupported entities to Google Assistant (#92105)
* Avoid exposing unsupported entities to Google Assistant

* Add Google Assistant specific support sets

* Add test
2023-04-27 19:58:36 +02:00
Raman Gupta 5c3094520d Fix vizio integration_type (#92103) 2023-04-27 19:58:32 +02:00
Paulus Schoutsen 8db1d13c71 Use pipeline ID in event (#92100)
* Use pipeline ID in event

* Fix tests
2023-04-27 19:58:28 +02:00
Paulus Schoutsen 47c6cb88a4 Fix capitalization names Assist entities (#92098)
* Fix capitalization names Assist entities

* Adjust names to be 'in progress'

* Update tests/components/esphome/test_binary_sensor.py

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>

---------

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2023-04-27 19:58:25 +02:00
Michael a1d4740785 Fix reconfigure by SSDP or Zeroconf discovery in Synology DSM (#92088) 2023-04-27 19:58:22 +02:00
Franck Nijhof b3d685cc31 Update YARL to 1.9.2 (#92086) 2023-04-27 19:58:19 +02:00
avee87 019f26a17c Remove name attribute from transmission services manifest (#92083) 2023-04-27 19:58:15 +02:00
puddly 9970af5fe9 Add a channel changing API to ZHA (#92076)
* Expose channel changing over the websocket API

* Expose channel changing as a service

* Type annotate some existing unit test fixtures

* Add unit tests

* Rename `api.change_channel` to `api.async_change_channel`

* Expand on channel migration in the service description

* Remove channel changing service, we only really need the websocket API

* Update homeassistant/components/zha/websocket_api.py

* Black

---------

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2023-04-27 19:58:12 +02:00
Michael Hansen f7e72ef62b Bump intents to 2023.4.26 (#92070)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2023-04-27 19:58:08 +02:00
Jesse Hills a445e29bca ESPHome voice assistant: Version 2 - Stream raw tts audio back to device for playback (#92052)
* Send raw audio back

* Update tests

* More tests

* Fix docstrings and remove unused patches

* More tests

* MORE

* Only set raw for v2
2023-04-27 19:58:04 +02:00
Jesse Hills ba69e29e8f Set pipeline_id from pipeline select (#92085) 2023-04-27 19:57:23 +02:00
Franck Nijhof 45d826c941 Bumped version to 2023.5.0b0 2023-04-26 21:56:02 +02:00
Franck Nijhof 583453f327 Merge branch 'master' into dev 2023-04-26 21:53:40 +02:00
Arturo 75be1b4ff9 Remove bridged matter devices when matter bridge is removed (#91995)
* Removes bridged matter devices when matter bridge is removed

* Didn't have the fetch the config entry since that is already provided to us

* Switched to a safer method of removing the child devices
2023-04-26 21:44:40 +02:00
puddly 2bbebeb925 Add a warning step to ZHA's config flow to advise against some radios (#92078) 2023-04-26 21:40:46 +02:00
puddly 4f660cc5f5 Allow the ZHA default light transition time to be configured as a float (#92075) 2023-04-26 21:24:06 +02:00
Marcel van der Veldt 3c44c7416f Fix Matter cover deviceclass and inverted position (#92063) 2023-04-26 21:21:31 +02:00
Bram Kragten e7e50243d1 Update frontend to 20230426.0 (#92074) 2023-04-26 21:19:06 +02:00
Franck Nijhof b6a3ffb20f Revert "Fail TTS tests if default TTS cache dir exists (#92023)" (#92079) 2023-04-26 21:18:17 +02:00
Jan Bouwhuis 5a78684998 Fix large imap_content event warning by truncating the email text body to 2 KiB (#92066) 2023-04-26 18:44:22 +02:00
J. Nick Koston ead761dfa2 Ensure device_automation can handle RequirementsNotFound (#92037) 2023-04-26 18:43:38 +02:00
Erik Montnemery 330a7afdfc Teach switch_as_x about exposed entities (#92059) 2023-04-26 18:42:49 +02:00
J. Nick Koston ec5f50913a Retry creating esphome update entities later if dashboard is unavailable (#92042) 2023-04-26 18:41:00 +02:00
J. Nick Koston f33e8c518f Ensure lutron_caseta logbook platform does not raise when integration is not loaded (#91978) 2023-04-26 18:39:42 +02:00
Bram Kragten aa4544accb Add assist pipeline and language selectors (#92030) 2023-04-26 18:39:02 +02:00
Jan Bouwhuis f6d8859dd2 Add codeowner for imap integration (#92067) 2023-04-26 18:21:58 +02:00
Erik Montnemery ce99319ea5 Add LED settings support to Home Assistant Yellow (#86451)
* Add LED control support to Home Assistant Yellow

* Fix the handlers

* Remove switch platform

* Allow configuring LED settings from the options flow

* Add missing translations

* Add tests

* Add tests
2023-04-26 11:02:52 -04:00
Ondřej Kolenatý 64e4414a5e Add today's remaining production estimate (#91965) 2023-04-26 15:58:28 +02:00
Erik Montnemery 32ffedd365 Fail TTS tests if default TTS cache dir exists (#92023)
Fail tests if default tts cache dir exists
2023-04-26 15:28:48 +02:00
Erik Montnemery 904ce226fb Tweak response of /api/cloud/login (#92058) 2023-04-26 15:28:08 +02:00
Franck Nijhof 565b26e884 Fix tts add-on discovery for Wyoming (#92064) 2023-04-26 08:04:46 -05:00
Matthias Alphart 0b9fbb1800 Fix typo in Nextcloud YAML deprecation message (#92060) 2023-04-26 13:51:47 +02:00
Erik Montnemery 2750a5c3e6 Make assist_pipeline an after dependency of cloud (#92057) 2023-04-26 13:45:32 +02:00
Paulus Schoutsen cdbdf1ba4f 2023.4.6 (#91833) 2023-04-21 20:31:05 -04:00
J. Nick Koston d58f62cb5e Remove old migration tests that have been replaced (#91842)
These tests were moved to test_migration_from_schema_32.py in `dev`
and have changed. Remove the old tests as they are no longer
correct
2023-04-21 19:12:21 -04:00
Paulus Schoutsen f1c4605fba Bumped version to 2023.4.6 2023-04-21 14:58:46 -04:00
Paulus Schoutsen deb55a74da Disallow uploading files to bypass the media dirs (#91817) 2023-04-21 14:58:42 -04:00
Allen Porter 30da629285 Relax the constraint that events must have a consistent timezone for start/end (#91788) 2023-04-21 14:58:41 -04:00
Stephan Uhle 26b28001c5 Bump pysml to 0.0.10 (#91773) 2023-04-21 14:58:40 -04:00
Nathan Spencer 64f8059f00 Bump pylitterbot to 2023.4.0 (#91759) 2023-04-21 14:58:39 -04:00
Jan Bouwhuis 8363183943 Do not wait for mqtt at startup mqtt_statestream (#91721) 2023-04-21 14:58:38 -04:00
Teemu R e19279fda5 Bump python-songpal dependency (#91708) 2023-04-21 14:58:38 -04:00
J. Nick Koston 591ffe2340 Fallback to generating a new ULID on migraiton if context is missing or invalid (#91704)
* Fallback to generating a new ULID on migraiton if context is missing or invalid

It was discovered that postgresql will do a full scan if
there is a low cardinality on the index because of missing
context ids. We will now generate a ULID for the timestamp
of the row if the context data is missing or invalid

fixes #91514

* tests

* tweak

* tweak

* preen
2023-04-21 14:58:37 -04:00
Shay Levy fc4e8e5e7b Bump aioshelly to 5.3.2 (#91679) 2023-04-21 14:58:36 -04:00
J. Nick Koston 36d2accb5b Handle long format context UUIDs during migration (#91657)
In https://github.com/home-assistant/core/issues/91514 is was discovered
these exist in older versions
2023-04-21 14:58:07 -04:00
epenet 38de9765df Bump renault-api to 0.1.13 (#91609) 2023-04-21 14:55:51 -04:00
Duco Sebel 6b02892c28 Handle UnsupportedError in HomeWizard (#91608)
* Handle UnsupportedEror

* Make error message more clear

* Remove debug line, whoops
2023-04-21 14:55:50 -04:00
Tom Harris c544da7426 Fix Insteon thermostat issue (#91568)
* Bump pyinsteon

* Bump pyinsteon

* Bump pyinsteon
2023-04-21 14:55:49 -04:00
Aaron Godfrey 71f0f53ddc Fix tasks with no due date from not triggering on calendar state. (#91196)
Fix tasks with no due date.

Prior to this change we were setting the start date/time to utc rather
than the user's timezone.
2023-04-21 14:55:48 -04:00
J. Nick Koston 03c517b066 Add a guard against selecting all entities in state_changes_during_period (#91585)
Add a guard against selecting all entities in state_changes_during_period

This cannot happen in `dev` because we require entity ids
2023-04-18 09:26:41 -04:00
Paulus Schoutsen b05fcd7904 2023.4.5 (#91544) 2023-04-17 22:20:19 -04:00
Franck Nijhof 940861e2be Bumped version to 2023.4.5 2023-04-17 15:37:08 +02:00
Christopher Bailey 559ce6a275 Bump unifiprotect to 4.8.1 (#91522) 2023-04-17 15:36:51 +02:00
rappenze 273e1fd2be Fix state mapping in fibaro climate (#91505) 2023-04-17 15:36:48 +02:00
Ben Morton 5ddc18f8ed Resolve issue with switchbot blind tilt devices getting stuck in opening/closing state (#91495) 2023-04-17 15:36:44 +02:00
J. Nick Koston 489a6e766b Fix onvif failing to reload (#91482) 2023-04-17 15:36:40 +02:00
starkillerOG 572f2cc167 Reolink ONVIF move read to primary callback (#91478)
* Move read to primary callback

* fix styling

* Do not raise on ConnectionResetError

* Split request.text() to .read() and decode("utf-8")
2023-04-17 15:36:37 +02:00
J. Nick Koston 5321c60058 Handle a few more transient onvif errors (#91473) 2023-04-17 15:36:33 +02:00
J. Nick Koston 00a86757fa Bump onvif-zeep-async to 1.2.11 (#91472) 2023-04-17 15:36:30 +02:00
J. Nick Koston b06d624d43 Fix creating onvif pull point subscriptions when InitialTerminationTime is required (#91470)
* Fix creating onvif pull point subscriptions when InitialTerminationTime is required

fixes #85902

* Bump again because I got it wrong the first time.. this is why retest is good
2023-04-17 15:36:26 +02:00
Michael Davie 89b1d5bb68 Bump env_canada to v0.5.33 (#91468) 2023-04-17 15:36:22 +02:00
Erik Montnemery bf389440dc Save Thread dataset store when changing preferred dataset (#91411) 2023-04-17 15:36:18 +02:00
puddly 2b9cc39d2b Fix attribute reporting config failures in ZHA (#91403) 2023-04-17 15:36:15 +02:00
J. Nick Koston afe3fd5ec0 Bump onvif-zeep-async to 1.2.5 (#91399) 2023-04-17 15:36:11 +02:00
Aidan Timson e29d5a1356 Fix listener running in foreground for System Bridge integration (#91391)
Co-authored-by: J. Nick Koston <nick@koston.org>
2023-04-17 15:36:08 +02:00
rich-kettlewell 5f7b447d7a Tado set_water_heater_timer should use water_heater domain (#91364) 2023-04-17 15:36:03 +02:00
epenet 0e3f462bfb Add missing mock in sharkiq tests (#91325) 2023-04-17 15:33:52 +02:00
starkillerOG 8feab57d59 Reolink prevent ONVIF push being lost due to ConnectionResetError (#91070)
* Make "Connection lost" error less likely

* Handle connection loss during ONVIF event reading

* tweak

* fix styling

* catch asyncio.CancelledError from request.text()

* missing ()

* re-raise cancelation for proper cleanup

* Simplify

* Also set webhook_reachable if connection lost

* fix styntax

* Send HTTP_OK directly after data read done

* protect agains garbage collection

* Protect shielded task (inner) not shielded future (outer)

* fix black

* Make sure exceptions are logged

* fix spelling

* fix black

* fix spelling

* Simplify using hass.async_create_task

* clarify comment

* Eleborate comment

* Update homeassistant/components/reolink/host.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Apply suggestions from bdraco

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
2023-04-17 15:28:25 +02:00
Mark Adkins 2bda40d352 Fix SharkIQ token expiration (#89357) 2023-04-17 15:27:44 +02:00
Meow 47398f03dd Add SetSynchronizationPoint fallback to onvif (#86400)
Co-authored-by: J. Nick Koston <nick@koston.org>
2023-04-17 15:22:17 +02:00
Franck Nijhof 3f0f5dc303 2023.4.4 (#91356) 2023-04-13 15:32:45 +02:00
Franck Nijhof b5ac3ee288 Bumped version to 2023.4.4 2023-04-13 13:50:25 +02:00
Bram Kragten 51c99d26b4 Update frontend to 20230411.1 (#91344) 2023-04-13 13:50:10 +02:00
J. Nick Koston f77ce413be Bump aiolifx to 0.8.10 (#91324) 2023-04-13 13:50:06 +02:00
Duco Sebel 7a8159052e Bump python-homewizard-energy to 2.0.1 (#91097) 2023-04-13 13:50:02 +02:00
Paulus Schoutsen 8ec6afb85a 2023.4.3 (#91316) 2023-04-12 21:50:11 -04:00
Franck Nijhof bbf2d0e6ad Remove codecov from Python test requirements (#91295) 2023-04-12 20:51:59 -04:00
tronikos c073cee049 Google Assistant SDK: Fix broadcast command for Portuguese (#91293)
Fix broadcast command for pt
2023-04-12 20:51:58 -04:00
Paulus Schoutsen e9f1148c0a Bumped version to 2023.4.3 2023-04-12 20:35:59 -04:00
J. Nick Koston a420007e80 Restore use of local timezone for MariaDB/MySQL in SQL integration (#91313)
* Use local timezone for recorder connection

The fix in #90335 had an unexpected side effect of
using UTC for the timezone since all recorder operations
use UTC. Since only sqlite much use the database executor
we can use a seperate connection pool which uses local time

This also ensures that the engines are disposed of
when Home Assistant is shutdown as previously we
did not cleanly disconnect

* coverage

* fix unclean shutdown in config flow

* tweaks
2023-04-12 20:35:50 -04:00
puddly 64a9bfcc22 Bump ZHA dependencies (#91291) 2023-04-12 20:35:49 -04:00
codyhackw fd53eda5c6 Update Inovelli Blue Series switch support in ZHA (#91254)
Co-authored-by: David F. Mulcahey <david.mulcahey@icloud.com>
2023-04-12 20:35:49 -04:00
Erik Montnemery d6574b4a2e Fix switch_as_x name (#91232) 2023-04-12 20:35:48 -04:00
Bram Kragten 8eb75beb96 Update frontend to 20230411.0 (#91219) 2023-04-12 20:35:47 -04:00
Erik Montnemery 68920a12aa Flush conversation name cache when an entity is renamed (#91214) 2023-04-12 20:35:46 -04:00
Aaron Bach a806e070a2 Bump pytile to 2023.04.0 (#91191) 2023-04-12 20:35:45 -04:00
David F. Mulcahey a87c78ca20 Cleanup ZHA from Zigpy deprecated property removal (#91180) 2023-04-12 20:35:44 -04:00
Aidan Timson 48df638f5d Reduce startup time for System Bridge integration (#91171) 2023-04-12 20:35:43 -04:00
Allen Porter c601266f9c Fix all day event coercion logic (#91169) 2023-04-12 20:35:42 -04:00
starkillerOG 30d615f206 Reolink config flow fix custom port when USE_HTTPS not selected (#91137)
give USE_HTTPS a default
2023-04-12 20:35:41 -04:00
J. Nick Koston 2db8d70c2f Fix false positive in SQL sensor full table scan check (#91134) 2023-04-12 20:35:40 -04:00
J. Nick Koston 3efffe7688 Bump ulid-transform to 0.6.3 (#91133)
* Bump ulid-transform to 0.6.2

changelog: https://github.com/bdraco/ulid-transform/compare/v0.6.0...v0.6.2

32bit fixes

fixes #91092

* 0.6.3
2023-04-12 20:35:39 -04:00
Allen Porter dc777f78b8 Relax calendar event validation to allow existing zero duration events (#91129)
Relax event valudation to allow existing zero duration events
2023-04-12 20:35:38 -04:00
Michael Davie 4cd00da319 Bump env_canada to 0.5.32 (#91126) 2023-04-12 20:35:37 -04:00
Robert Hillis 3f6486db3e Bump aiopyarr to 23.4.0 (#91110) 2023-04-12 20:35:36 -04:00
Diogo Gomes 2d41fe837c Track availability of source sensor in utility meter (#91035)
* track availability of source sensor

* address review comments
2023-04-12 20:35:35 -04:00
Pascal Reeb 34394d90c0 Fall back to polling if webhook cannot be registered on Nuki (#91013)
fix(nuki): throw warning if webhook cannot be created
2023-04-12 20:35:34 -04:00
Anthony Mattas fa29aea68e Fix configuring Flo instances (#90990)
* Update config_flow.py

Used constant string for consistency

* Update config_flow.py

Removed code for location ID and name the integration using the username

* Update manifest.json

Updated codeowners

* Update config_flow.py

* Update config_flow.py

Formatted with black

* Update manifest.json

Updated codeowners

* Update test_config_flow.py

Updated test
2023-04-12 20:35:33 -04:00
Paulus Schoutsen 7928b31087 2023.4.2 (#91111) 2023-04-08 23:41:48 -04:00
J. Nick Koston e792350be6 Fix fnvhash import in schema 32 test backport (#91112) 2023-04-08 23:41:19 -04:00
Paulus Schoutsen 5f0553dd22 Bumped version to 2023.4.2 2023-04-08 22:58:28 -04:00
J. Nick Koston 8f6b77235e Make the device_tracker more forgiving when passed an empty ip address string (#91101)
This has come up over and over and over again

fixes #87165 fixes #51980
2023-04-08 22:56:49 -04:00
J. Nick Koston 8ababc75d4 Bump flux_led to 0.28.37 (#91099)
changes: https://github.com/Danielhiversen/flux_led/releases/tag/0.28.37
2023-04-08 22:56:48 -04:00
J. Nick Koston 0a8f399655 Fix context_user_id round trip when calling to_native (#91098)
We do not actually use this in the history or logbook
APIs so nothing broke but there was a bug here for anyone
calling this directly

fixes #91090
2023-04-08 22:56:47 -04:00
Michael Davie 19567e7fee Bump env_canada to v0.5.31 (#91094) 2023-04-08 22:56:46 -04:00
Garrett 3a137cb24c Bump subarulink to 0.7.6 (#91064) 2023-04-08 22:56:45 -04:00
Allen Porter 935af6904d Bump gcal_sync to 4.1.4 (#91062) 2023-04-08 22:56:44 -04:00
Allen Porter 4fed5ad21c Make location optional in google calendar create service (#91061) 2023-04-08 22:56:44 -04:00
J. Nick Koston 9dc15687b5 Bump zeroconf to 0.56.0 (#91060) 2023-04-08 22:56:43 -04:00
J. Nick Koston 38a0eca223 Bump zeroconf to 0.55.0 (#90987) 2023-04-08 22:56:42 -04:00
David F. Mulcahey 6836e0b511 Fix Smartthings acceleration sensor in ZHA (#91056) 2023-04-08 22:55:52 -04:00
David F. Mulcahey cab88b72b8 Bump ZHA quirks lib (#91054) 2023-04-08 22:55:51 -04:00
Steven Looman 07421927ec Make sure upnp-router is also initialized when first seen through an advertisement (#91037) 2023-04-08 22:55:50 -04:00
Diogo Gomes 828a2779a0 Delay utility_meter until HA has started (#91017)
* increase information for end user

* only warn after home assistant has started

* delay utility_meter until HA has startED
2023-04-08 22:55:49 -04:00
Joost Lekkerkerker 7392a5780c Bump roombapy to 1.6.8 (#91012)
* Update roombapy to 1.6.7

* Update roombapy to 1.6.8
2023-04-08 22:55:48 -04:00
Aaron Bach 804270a797 Bump aioambient to 2023.04.0 (#90991) 2023-04-08 22:55:47 -04:00
J. Nick Koston 7f5f286648 Bump vallox-websocket-api to 3.2.1 (#90980)
unblocks https://github.com/home-assistant/core/pull/90901
which will finally fix the races in websockets
2023-04-08 22:55:46 -04:00
J. Nick Koston 0a70a29e92 Resume entity id post migration after a restart (#90973)
* Restart entity id post migration after a restart

If the entity migration finished and Home Assistant was
restarted during the post migration it would never be resumed
which means the old index and space would never be recovered

* add migration resume test
2023-04-08 22:55:46 -04:00
J. Nick Koston dc2f2e8d3f Raise an issue for legacy SQL queries that will cause full table scans (#90971)
* Raise an issue for SQL queries that will cause full table scans

* Raise an issue for SQL queries that will cause full table scans

* Raise an issue for SQL queries that will cause full table scans

* Raise an issue for SQL queries that will cause full table scans

* Update homeassistant/components/sql/sensor.py

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* coverage

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2023-04-08 22:55:45 -04:00
J. Nick Koston 6522a3ad1b Bump websockets constraint to 11.0.1+ (#90901) 2023-04-08 22:55:44 -04:00
PatrickGlesner be65d4f33e Fix NMBS AttributeError (#90525)
* Fix NMBS AttributeError (Issue #90505)

* Set and use API_FAILURE

* Configure the logger to track API failures

* Remove broad exceptions and rewite logging
2023-04-08 22:55:43 -04:00
Paulus Schoutsen 0c15c75781 2023.4.1 (#90956) 2023-04-06 17:52:14 -04:00
Heikki Partanen 2bf51a033b Fix verisure autolock (#90960)
Fix verisure autolock #90959
2023-04-06 20:54:40 +00:00
Steven Rollason cfd8695aaa Fix command_template sensor value_template not being used if json_attributes set (#90603)
* Allow value_template to be used if json_attributes set

* Set state to None if no value_template and json_attributes used

* Refactor check for no value_template when json_attributes used

* Updated and additional unit test

* Updated to set _attr_native_value and return if value_template is None

* Update unit test docstring

* Updated test docstring based on feedback
2023-04-06 20:49:32 +00:00
Jan Bouwhuis e8a6a2e105 Fix error after losing an imap connection (#90966)
Cleanup first after losing an imap connection
2023-04-06 20:46:54 +00:00
Allen Porter 73a960af34 Bump gcal_sync to 4.1.3 (#90968) 2023-04-06 20:44:52 +00:00
Allen Porter bbb571fdf8 Coerce previously persisted local calendars to have valid durations (#90970) 2023-04-06 20:42:00 +00:00
J. Nick Koston c944be8215 Fix state being cleared on disconnect with deep sleep esphome devices (#90925)
* Fix state being cleared on disconnect with deep sleep esphome devices

fixes #90923

* fix logic
2023-04-06 20:39:04 +00:00
J. Nick Koston 5e903e04cf Avoid writing state to all esphome entities at shutdown (#90555) 2023-04-06 20:39:00 +00:00
starkillerOG 6884b0a421 Bump reolink-aio to 0.5.10 (#90963)
* use is_doorbell instead of is_doorbell_enabled

* Bump reolink-aio to 0.5.10
2023-04-06 14:35:39 -04:00
Aaron Bach a1c7159304 Bump aioambient to 2022.10.0 (#90940)
Co-authored-by: J. Nick Koston <nick@koston.org>
2023-04-06 14:34:25 -04:00
epenet d65791027f Fix flaky test in vesync (#90921)
* Fix flaky test in vesync

* Move sorting to the test
2023-04-06 14:34:24 -04:00
Paulus Schoutsen 5ffa0cba39 Bumped version to 2023.4.1 2023-04-06 13:21:13 -04:00
Bram Kragten f5be600383 Update frontend to 20230406.1 (#90951) 2023-04-06 13:21:07 -04:00
Pascal Reeb 9b2e26c270 Handle NoURLAvailableError in Nuki component (#90927)
* fix(nuki): handle NoURLAvailableError

* only try internal URLs
2023-04-06 13:21:06 -04:00
stickpin e25edea815 Return empty available programs list if an appliance is off during initial configuration (#90905) 2023-04-06 13:21:05 -04:00
J. Nick Koston 849000d5ac Bump aiodiscover to 1.4.16 (#90903) 2023-04-06 13:21:04 -04:00
Aaron Bach cb06541fda Bump simplisafe-python to 2023.04.0 (#90896)
Co-authored-by: J. Nick Koston <nick@koston.org>
2023-04-06 13:21:03 -04:00
J. Nick Koston 70d1e733f6 Fix entity_id migration query failing with MySQL 8.0.30 (#90895) 2023-04-06 13:21:02 -04:00
J. Nick Koston 0b3012071e Guard against invalid ULIDs in contexts while recording events (#90889) 2023-04-06 13:21:01 -04:00
J. Nick Koston 42b7ed115f Bump ulid-transform 0.6.0 (#90888)
* Bump ulid-transform 0.6.0

changelog: https://github.com/bdraco/ulid-transform/compare/v0.5.1...v0.6.0

to find the source of the invalid ulids in https://github.com/home-assistant/core/issues/90887
2023-04-06 13:21:00 -04:00
J. Nick Koston 513a13f369 Fix missing bluetooth client wrapper in bleak_retry_connector (#90885) 2023-04-06 13:20:59 -04:00
Michael f341d0787e Migrate entity unique ids in PI-Hole (#90883)
* migrate entity unique ids

* Update homeassistant/components/pi_hole/__init__.py

---------

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2023-04-06 13:20:58 -04:00
J. Nick Koston c8ee45b53c Add MariaDB deadlock retry wrapper to database timestamp column migrations (#90880)
Add deadlock retry wrapper to timestamp column migrations

fixes #90819
2023-04-06 13:20:57 -04:00
J. Nick Koston b4e2dd4e06 Add constraint for websockets to <11.0 (#90868) 2023-04-06 13:20:56 -04:00
J. Nick Koston c663d8754b Generate a seperate log message per dumped object for profiler.dump_log_objects (#90867)
Since some objects are very large we can generate overly large log messages
```
Event data for system_log_event exceed maximum size of 32768 bytes. This can cause database performance issues; Event data will not be stored
```

Reported in https://ptb.discord.com/channels/330944238910963714/427516175237382144/1093069996101472306
2023-04-06 13:20:55 -04:00
Tom Harris 968a4e4818 Fix issue with Insteon All-Link Database loading (#90858)
Bump to 1.4.1
2023-04-06 13:20:54 -04:00
saschaabraham 833b95722e Bump fritzconnection to 1.12.0 (#90799) 2023-04-06 13:20:53 -04:00
mkmer 096e814929 Handle Uncaught exceptions in async_update Honeywell (#90746) 2023-04-06 13:20:52 -04:00
119 changed files with 3115 additions and 453 deletions
+2 -2
View File
@@ -550,8 +550,8 @@ build.json @home-assistant/supervisor
/tests/components/image_processing/ @home-assistant/core
/homeassistant/components/image_upload/ @home-assistant/core
/tests/components/image_upload/ @home-assistant/core
/homeassistant/components/imap/ @engrbm87
/tests/components/imap/ @engrbm87
/homeassistant/components/imap/ @engrbm87 @jbouwh
/tests/components/imap/ @engrbm87 @jbouwh
/homeassistant/components/incomfort/ @zxdavb
/homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01
@@ -324,18 +324,29 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
all_identifiers = set(self.atv.all_identifiers)
discovered_ip_address = str(self.atv.address)
for entry in self._async_current_entries():
if not all_identifiers.intersection(
existing_identifiers = set(
entry.data.get(CONF_IDENTIFIERS, [entry.unique_id])
):
)
if not all_identifiers.intersection(existing_identifiers):
continue
if entry.data.get(CONF_ADDRESS) != discovered_ip_address:
combined_identifiers = existing_identifiers | all_identifiers
if entry.data.get(
CONF_ADDRESS
) != discovered_ip_address or combined_identifiers != set(
entry.data.get(CONF_IDENTIFIERS, [])
):
self.hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_ADDRESS: discovered_ip_address},
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(entry.entry_id)
data={
**entry.data,
CONF_ADDRESS: discovered_ip_address,
CONF_IDENTIFIERS: list(combined_identifiers),
},
)
if entry.source != config_entries.SOURCE_IGNORE:
self.hass.async_create_task(
self.hass.config_entries.async_reload(entry.entry_id)
)
if not allow_exist:
raise DeviceAlreadyConfigured()
@@ -23,6 +23,7 @@ from homeassistant.helpers.collection import (
StorageCollection,
StorageCollectionWebsocket,
)
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util import (
dt as dt_util,
@@ -369,7 +370,7 @@ class PipelineRun:
def start(self) -> None:
"""Emit run start event."""
data = {
"pipeline": self.pipeline.name,
"pipeline": self.pipeline.id,
"language": self.language,
}
if self.runner_data is not None:
@@ -956,7 +957,8 @@ class PipelineRunDebug:
)
async def async_setup_pipeline_store(hass: HomeAssistant) -> None:
@singleton(DOMAIN)
async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData:
"""Set up the pipeline storage collection."""
pipeline_store = PipelineStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY)
@@ -969,4 +971,4 @@ async def async_setup_pipeline_store(hass: HomeAssistant) -> None:
PIPELINE_FIELDS,
PIPELINE_FIELDS,
).async_setup(hass)
hass.data[DOMAIN] = PipelineData({}, pipeline_store)
return PipelineData({}, pipeline_store)
@@ -1,8 +1,13 @@
{
"entity": {
"binary_sensor": {
"assist_in_progress": {
"name": "Assist in progress"
}
},
"select": {
"pipeline": {
"name": "Assist Pipeline",
"name": "Assist pipeline",
"state": {
"preferred": "Preferred"
}
+5 -1
View File
@@ -39,7 +39,11 @@ async def async_setup_entry(
class BAFFan(BAFEntity, FanEntity):
"""BAF ceiling fan component."""
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.DIRECTION
| FanEntityFeature.PRESET_MODE
)
_attr_preset_modes = [PRESET_MODE_AUTO]
_attr_speed_count = SPEED_COUNT
+2 -1
View File
@@ -89,7 +89,8 @@ class BondFan(BondEntity, FanEntity):
features |= FanEntityFeature.SET_SPEED
if self._device.supports_direction():
features |= FanEntityFeature.DIRECTION
if self._device.has_action(Action.BREEZE_ON):
features |= FanEntityFeature.PRESET_MODE
return features
@property
+72 -2
View File
@@ -20,14 +20,17 @@ from homeassistant.components.alexa import (
errors as alexa_errors,
state_report as alexa_state_report,
)
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.homeassistant.exposed_entities import (
async_get_assistant_settings,
async_listen_entity_updates,
async_should_expose,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.helpers import entity_registry as er, start
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.event import async_call_later
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
@@ -51,6 +54,69 @@ CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}"
SYNC_DELAY = 1
SUPPORTED_DOMAINS = {
"alarm_control_panel",
"alert",
"automation",
"button",
"camera",
"climate",
"cover",
"fan",
"group",
"humidifier",
"image_processing",
"input_boolean",
"input_button",
"input_number",
"light",
"lock",
"media_player",
"number",
"scene",
"script",
"switch",
"timer",
"vacuum",
}
SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES = {
BinarySensorDeviceClass.DOOR,
BinarySensorDeviceClass.GARAGE_DOOR,
BinarySensorDeviceClass.MOTION,
BinarySensorDeviceClass.OPENING,
BinarySensorDeviceClass.PRESENCE,
BinarySensorDeviceClass.WINDOW,
}
SUPPORTED_SENSOR_DEVICE_CLASSES = {
SensorDeviceClass.TEMPERATURE,
}
def entity_supported(hass: HomeAssistant, entity_id: str) -> bool:
"""Return if the entity is supported.
This is called when migrating from legacy config format to avoid exposing
all binary sensors and sensors.
"""
domain = split_entity_id(entity_id)[0]
if domain in SUPPORTED_DOMAINS:
return True
device_class = get_device_class(hass, entity_id)
if (
domain == "binary_sensor"
and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES
):
return True
if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES:
return True
return False
class CloudAlexaConfig(alexa_config.AbstractConfig):
"""Alexa Configuration."""
@@ -183,9 +249,13 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
# Backwards compat
if (default_expose := self._prefs.alexa_default_expose) is None:
return not auxiliary_entity
return not auxiliary_entity and entity_supported(self.hass, entity_id)
return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose
return (
not auxiliary_entity
and split_entity_id(entity_id)[0] in default_expose
and entity_supported(self.hass, entity_id)
)
def should_expose(self, entity_id):
"""If an entity should be exposed."""
@@ -7,12 +7,14 @@ from typing import Any
from hass_nabucasa import Cloud, cloud_api
from hass_nabucasa.google_report_state import ErrorResponse
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN
from homeassistant.components.google_assistant.helpers import AbstractConfig
from homeassistant.components.homeassistant.exposed_entities import (
async_listen_entity_updates,
async_should_expose,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import (
CoreState,
@@ -22,6 +24,7 @@ from homeassistant.core import (
split_entity_id,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er, start
from homeassistant.helpers.entity import get_device_class
from homeassistant.setup import async_setup_component
from .const import (
@@ -39,6 +42,73 @@ _LOGGER = logging.getLogger(__name__)
CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}"
SUPPORTED_DOMAINS = {
"alarm_control_panel",
"button",
"camera",
"climate",
"cover",
"fan",
"group",
"humidifier",
"input_boolean",
"input_button",
"input_select",
"light",
"lock",
"media_player",
"scene",
"script",
"select",
"switch",
"vacuum",
}
SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES = {
BinarySensorDeviceClass.DOOR,
BinarySensorDeviceClass.GARAGE_DOOR,
BinarySensorDeviceClass.LOCK,
BinarySensorDeviceClass.MOTION,
BinarySensorDeviceClass.OPENING,
BinarySensorDeviceClass.PRESENCE,
BinarySensorDeviceClass.WINDOW,
}
SUPPORTED_SENSOR_DEVICE_CLASSES = {
SensorDeviceClass.AQI,
SensorDeviceClass.CO,
SensorDeviceClass.CO2,
SensorDeviceClass.HUMIDITY,
SensorDeviceClass.PM10,
SensorDeviceClass.PM25,
SensorDeviceClass.TEMPERATURE,
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
}
def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool:
"""Return if the entity is supported.
This is called when migrating from legacy config format to avoid exposing
all binary sensors and sensors.
"""
domain = split_entity_id(entity_id)[0]
if domain in SUPPORTED_DOMAINS:
return True
device_class = get_device_class(hass, entity_id)
if (
domain == "binary_sensor"
and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES
):
return True
if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES:
return True
return False
class CloudGoogleConfig(AbstractConfig):
"""HA Cloud Configuration for Google Assistant."""
@@ -180,9 +250,13 @@ class CloudGoogleConfig(AbstractConfig):
# Backwards compat
if default_expose is None:
return not auxiliary_entity
return not auxiliary_entity and _supported_legacy(self.hass, entity_id)
return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose
return (
not auxiliary_entity
and split_entity_id(entity_id)[0] in default_expose
and _supported_legacy(self.hass, entity_id)
)
def _should_expose_entity_id(self, entity_id):
"""If an entity should be exposed."""
+49 -3
View File
@@ -29,6 +29,7 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.location import async_detect_location_info
from .alexa_config import entity_supported as entity_supported_by_alexa
from .const import (
DOMAIN,
PREF_ALEXA_REPORT_STATE,
@@ -73,6 +74,7 @@ async def async_setup(hass):
websocket_api.async_register_command(hass, google_assistant_list)
websocket_api.async_register_command(hass, google_assistant_update)
websocket_api.async_register_command(hass, alexa_get)
websocket_api.async_register_command(hass, alexa_list)
websocket_api.async_register_command(hass, alexa_sync)
@@ -198,12 +200,16 @@ class CloudLoginView(HomeAssistantView):
cloud = hass.data[DOMAIN]
await cloud.login(data["email"], data["password"])
if (cloud_pipeline_id := cloud_assist_pipeline(hass)) is None:
# Make sure the pipeline store is loaded, needed because assist_pipeline
# is an after dependency of cloud
await assist_pipeline.async_setup_pipeline_store(hass)
new_cloud_pipeline_id: str | None = None
if (cloud_assist_pipeline(hass)) is None:
if cloud_pipeline := await assist_pipeline.async_create_default_pipeline(
hass, DOMAIN, DOMAIN
):
cloud_pipeline_id = cloud_pipeline.id
return self.json({"success": True, "cloud_pipeline": cloud_pipeline_id})
new_cloud_pipeline_id = cloud_pipeline.id
return self.json({"success": True, "cloud_pipeline": new_cloud_pipeline_id})
class CloudLogoutView(HomeAssistantView):
@@ -664,6 +670,46 @@ async def google_assistant_update(
connection.send_result(msg["id"])
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command(
{
"type": "cloud/alexa/entities/get",
"entity_id": str,
}
)
@websocket_api.async_response
@_ws_handle_cloud_errors
async def alexa_get(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get data for a single alexa entity."""
entity_registry = er.async_get(hass)
entity_id: str = msg["entity_id"]
if not entity_registry.async_is_registered(entity_id):
connection.send_error(
msg["id"],
websocket_api.const.ERR_NOT_FOUND,
f"{entity_id} not in the entity registry",
)
return
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity_supported_by_alexa(
hass, entity_id
):
connection.send_error(
msg["id"],
websocket_api.const.ERR_NOT_SUPPORTED,
f"{entity_id} not supported by Alexa",
)
return
connection.send_result(msg["id"])
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command({"type": "cloud/alexa/entities"})
+2 -2
View File
@@ -1,9 +1,9 @@
{
"domain": "cloud",
"name": "Home Assistant Cloud",
"after_dependencies": ["google_assistant", "alexa"],
"after_dependencies": ["assist_pipeline", "google_assistant", "alexa"],
"codeowners": ["@home-assistant/cloud"],
"dependencies": ["assist_pipeline", "homeassistant", "http", "webhook"],
"dependencies": ["homeassistant", "http", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/cloud",
"integration_type": "system",
"iot_class": "cloud_push",
@@ -23,7 +23,7 @@ from homeassistant.util import language as language_util
from .agent import AbstractConversationAgent, ConversationInput, ConversationResult
from .const import HOME_ASSISTANT_AGENT
from .default_agent import DefaultAgent
from .default_agent import DefaultAgent, async_setup as async_setup_default_agent
__all__ = [
"DOMAIN",
@@ -93,7 +93,9 @@ CONFIG_SCHEMA = vol.Schema(
@core.callback
def _get_agent_manager(hass: HomeAssistant) -> AgentManager:
"""Get the active agent."""
return AgentManager(hass)
manager = AgentManager(hass)
manager.async_setup()
return manager
@core.callback
@@ -389,7 +391,11 @@ class AgentManager:
"""Initialize the conversation agents."""
self.hass = hass
self._agents: dict[str, AbstractConversationAgent] = {}
self._default_agent_init_lock = asyncio.Lock()
self._builtin_agent_init_lock = asyncio.Lock()
def async_setup(self) -> None:
"""Set up the conversation agents."""
async_setup_default_agent(self.hass)
async def async_get_agent(
self, agent_id: str | None = None
@@ -402,7 +408,7 @@ class AgentManager:
if self._builtin_agent is not None:
return self._builtin_agent
async with self._default_agent_init_lock:
async with self._builtin_agent_init_lock:
if self._builtin_agent is not None:
return self._builtin_agent
@@ -73,6 +73,26 @@ def _get_language_variations(language: str) -> Iterable[str]:
yield lang
@core.callback
def async_setup(hass: core.HomeAssistant) -> None:
"""Set up entity registry listener for the default agent."""
entity_registry = er.async_get(hass)
for entity_id in entity_registry.entities:
async_should_expose(hass, DOMAIN, entity_id)
@core.callback
def async_handle_entity_registry_changed(event: core.Event) -> None:
"""Set expose flag on newly created entities."""
if event.data["action"] == "create":
async_should_expose(hass, DOMAIN, event.data["entity_id"])
hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED,
async_handle_entity_registry_changed,
run_immediately=True,
)
class DefaultAgent(AbstractConversationAgent):
"""Default agent for conversation agent."""
@@ -472,10 +492,10 @@ class DefaultAgent(AbstractConversationAgent):
return self._slot_lists
area_ids_with_entities: set[str] = set()
all_entities = er.async_get(self.hass)
entity_registry = er.async_get(self.hass)
entities = [
entity
for entity in all_entities.entities.values()
for entity in entity_registry.entities.values()
if async_should_expose(self.hass, DOMAIN, entity.entity_id)
]
devices = dr.async_get(self.hass)
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["hassil==1.0.6", "home-assistant-intents==2023.4.17-1"]
"requirements": ["hassil==1.0.6", "home-assistant-intents==2023.4.26"]
}
@@ -29,7 +29,10 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import IntegrationNotFound
from homeassistant.requirements import async_get_integration_with_requirements
from homeassistant.requirements import (
RequirementsNotFound,
async_get_integration_with_requirements,
)
from .const import ( # noqa: F401
CONF_IS_OFF,
@@ -171,6 +174,10 @@ async def async_get_device_automation_platform(
raise InvalidDeviceAutomationConfig(
f"Integration '{domain}' not found"
) from err
except RequirementsNotFound as err:
raise InvalidDeviceAutomationConfig(
f"Integration '{domain}' could not be loaded"
) from err
except ImportError as err:
raise InvalidDeviceAutomationConfig(
f"Integration '{domain}' does not support device automation "
+18 -11
View File
@@ -288,39 +288,46 @@ async def async_setup_entry( # noqa: C901
voice_assistant_udp_server: VoiceAssistantUDPServer | None = None
def handle_pipeline_event(
def _handle_pipeline_event(
event_type: VoiceAssistantEventType, data: dict[str, str] | None
) -> None:
"""Handle a voice assistant pipeline event."""
cli.send_voice_assistant_event(event_type, data)
async def handle_pipeline_start() -> int | None:
def _handle_pipeline_finished() -> None:
nonlocal voice_assistant_udp_server
entry_data.async_set_assist_pipeline_state(False)
if voice_assistant_udp_server is not None:
voice_assistant_udp_server.close()
voice_assistant_udp_server = None
async def _handle_pipeline_start() -> int | None:
"""Start a voice assistant pipeline."""
nonlocal voice_assistant_udp_server
if voice_assistant_udp_server is not None:
return None
voice_assistant_udp_server = VoiceAssistantUDPServer(hass)
voice_assistant_udp_server = VoiceAssistantUDPServer(
hass, entry_data, _handle_pipeline_event, _handle_pipeline_finished
)
port = await voice_assistant_udp_server.start_server()
hass.async_create_background_task(
voice_assistant_udp_server.run_pipeline(handle_pipeline_event),
voice_assistant_udp_server.run_pipeline(),
"esphome.voice_assistant_udp_server.run_pipeline",
)
entry_data.async_set_assist_pipeline_state(True)
return port
async def handle_pipeline_stop() -> None:
async def _handle_pipeline_stop() -> None:
"""Stop a voice assistant pipeline."""
nonlocal voice_assistant_udp_server
entry_data.async_set_assist_pipeline_state(False)
if voice_assistant_udp_server is not None:
voice_assistant_udp_server.stop()
voice_assistant_udp_server = None
async def on_connect() -> None:
"""Subscribe to states and list entities on successful API login."""
@@ -369,8 +376,8 @@ async def async_setup_entry( # noqa: C901
if device_info.voice_assistant_version:
entry_data.disconnect_callbacks.append(
await cli.subscribe_voice_assistant(
handle_pipeline_start,
handle_pipeline_stop,
_handle_pipeline_start,
_handle_pipeline_stop,
)
)
@@ -34,7 +34,7 @@ async def async_setup_entry(
entry_data = DomainData.get(hass).get_entry_data(entry)
assert entry_data.device_info is not None
if entry_data.device_info.voice_assistant_version:
async_add_entities([EsphomeCallActiveBinarySensor(entry_data)])
async_add_entities([EsphomeAssistInProgressBinarySensor(entry_data)])
class EsphomeBinarySensor(
@@ -68,12 +68,12 @@ class EsphomeBinarySensor(
return super().available
class EsphomeCallActiveBinarySensor(EsphomeAssistEntity, BinarySensorEntity):
class EsphomeAssistInProgressBinarySensor(EsphomeAssistEntity, BinarySensorEntity):
"""A binary sensor implementation for ESPHome for use with assist_pipeline."""
entity_description = BinarySensorEntityDescription(
key="call_active",
translation_key="call_active",
key="assist_in_progress",
translation_key="assist_in_progress",
)
@property
@@ -48,8 +48,8 @@
},
"entity": {
"binary_sensor": {
"call_active": {
"name": "Call Active"
"assist_in_progress": {
"name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]"
}
},
"select": {
+24 -19
View File
@@ -13,7 +13,7 @@ from homeassistant.components.update import (
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
@@ -33,35 +33,36 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up ESPHome update based on a config entry."""
dashboard = async_get_dashboard(hass)
if dashboard is None:
if (dashboard := async_get_dashboard(hass)) is None:
return
entry_data = DomainData.get(hass).get_entry_data(entry)
unsub = None
unsubs: list[CALLBACK_TYPE] = []
async def setup_update_entity() -> None:
@callback
def _async_setup_update_entity() -> None:
"""Set up the update entity."""
nonlocal unsub
nonlocal unsubs
assert dashboard is not None
# Keep listening until device is available
if not entry_data.available:
if not entry_data.available or not dashboard.last_update_success:
return
if unsub is not None:
unsub() # type: ignore[unreachable]
for unsub in unsubs:
unsub()
unsubs.clear()
assert dashboard is not None
async_add_entities([ESPHomeUpdateEntity(entry_data, dashboard)])
if entry_data.available:
await setup_update_entity()
if entry_data.available and dashboard.last_update_success:
_async_setup_update_entity()
return
unsub = async_dispatcher_connect(
hass, entry_data.signal_device_updated, setup_update_entity
)
unsubs = [
async_dispatcher_connect(
hass, entry_data.signal_device_updated, _async_setup_update_entity
),
dashboard.async_add_listener(_async_setup_update_entity),
]
class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
@@ -88,7 +89,11 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
# If the device has deep sleep, we can't assume we can install updates
# as the ESP will not be connectable (by design).
if coordinator.supports_update and not self._device_info.has_deep_sleep:
if (
coordinator.last_update_success
and coordinator.supports_update
and not self._device_info.has_deep_sleep
):
self._attr_supported_features = UpdateEntityFeature.INSTALL
@property
@@ -8,21 +8,26 @@ import socket
from typing import cast
from aioesphomeapi import VoiceAssistantEventType
import async_timeout
from homeassistant.components import stt
from homeassistant.components import stt, tts
from homeassistant.components.assist_pipeline import (
PipelineEvent,
PipelineEventType,
async_pipeline_from_audio_stream,
select as pipeline_select,
)
from homeassistant.components.media_player import async_process_play_media_url
from homeassistant.core import Context, HomeAssistant, callback
from .const import DOMAIN
from .entry_data import RuntimeEntryData
from .enum_mapper import EsphomeEnumMapper
_LOGGER = logging.getLogger(__name__)
UDP_PORT = 0 # Set to 0 to let the OS pick a free random port
UDP_MAX_PACKET_SIZE = 1024
_VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[
VoiceAssistantEventType, PipelineEventType
@@ -47,12 +52,26 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
started = False
queue: asyncio.Queue[bytes] | None = None
transport: asyncio.DatagramTransport | None = None
remote_addr: tuple[str, int] | None = None
def __init__(self, hass: HomeAssistant) -> None:
def __init__(
self,
hass: HomeAssistant,
entry_data: RuntimeEntryData,
handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None],
handle_finished: Callable[[], None],
) -> None:
"""Initialize UDP receiver."""
self.context = Context()
self.hass = hass
assert entry_data.device_info is not None
self.device_info = entry_data.device_info
self.queue = asyncio.Queue()
self.handle_event = handle_event
self.handle_finished = handle_finished
self._tts_done = asyncio.Event()
async def start_server(self) -> int:
"""Start accepting connections."""
@@ -86,6 +105,10 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
@callback
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
"""Handle incoming UDP packet."""
if not self.started:
return
if self.remote_addr is None:
self.remote_addr = addr
if self.queue is not None:
self.queue.put_nowait(data)
@@ -95,12 +118,18 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
(Other than BlockingIOError or InterruptedError.)
"""
_LOGGER.error("ESPHome Voice Assistant UDP server error received: %s", exc)
self.handle_finished()
@callback
def stop(self) -> None:
"""Stop the receiver."""
if self.queue is not None:
self.queue.put_nowait(b"")
self.started = False
def close(self) -> None:
"""Close the receiver."""
if self.queue is not None:
self.queue = None
if self.transport is not None:
self.transport.close()
@@ -113,54 +142,112 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
while data := await self.queue.get():
yield data
def _event_callback(self, event: PipelineEvent) -> None:
"""Handle pipeline events."""
try:
event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type)
except KeyError:
_LOGGER.warning("Received unknown pipeline event type: %s", event.type)
return
data_to_send = None
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
assert event.data is not None
data_to_send = {"text": event.data["stt_output"]["text"]}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
assert event.data is not None
data_to_send = {"text": event.data["tts_input"]}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
assert event.data is not None
path = event.data["tts_output"]["url"]
url = async_process_play_media_url(self.hass, path)
data_to_send = {"url": url}
if self.device_info.voice_assistant_version >= 2:
media_id = event.data["tts_output"]["media_id"]
self.hass.async_create_background_task(
self._send_tts(media_id), "esphome_voice_assistant_tts"
)
else:
self._tts_done.set()
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
assert event.data is not None
data_to_send = {
"code": event.data["code"],
"message": event.data["message"],
}
self.handle_finished()
self.handle_event(event_type, data_to_send)
async def run_pipeline(
self,
handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None],
pipeline_timeout: float = 30.0,
) -> None:
"""Run the Voice Assistant pipeline."""
try:
tts_audio_output = (
"raw" if self.device_info.voice_assistant_version >= 2 else "mp3"
)
async with async_timeout.timeout(pipeline_timeout):
await async_pipeline_from_audio_stream(
self.hass,
context=self.context,
event_callback=self._event_callback,
stt_metadata=stt.SpeechMetadata(
language="", # set in async_pipeline_from_audio_stream
format=stt.AudioFormats.WAV,
codec=stt.AudioCodecs.PCM,
bit_rate=stt.AudioBitRates.BITRATE_16,
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
),
stt_stream=self._iterate_packets(),
pipeline_id=pipeline_select.get_chosen_pipeline(
self.hass, DOMAIN, self.device_info.mac_address
),
tts_audio_output=tts_audio_output,
)
@callback
def handle_pipeline_event(event: PipelineEvent) -> None:
"""Handle pipeline events."""
# Block until TTS is done sending
await self._tts_done.wait()
try:
event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type)
except KeyError:
_LOGGER.warning("Received unknown pipeline event type: %s", event.type)
_LOGGER.debug("Pipeline finished")
except asyncio.TimeoutError:
_LOGGER.warning("Pipeline timeout")
finally:
self.handle_finished()
async def _send_tts(self, media_id: str) -> None:
"""Send TTS audio to device via UDP."""
try:
if self.transport is None:
return
data_to_send = None
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
assert event.data is not None
data_to_send = {"text": event.data["stt_output"]["text"]}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
assert event.data is not None
data_to_send = {"text": event.data["tts_input"]}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
assert event.data is not None
path = event.data["tts_output"]["url"]
url = async_process_play_media_url(self.hass, path)
data_to_send = {"url": url}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
assert event.data is not None
data_to_send = {
"code": event.data["code"],
"message": event.data["message"],
}
_extension, audio_bytes = await tts.async_get_media_source_audio(
self.hass,
media_id,
)
handle_event(event_type, data_to_send)
_LOGGER.debug("Sending %d bytes of audio", len(audio_bytes))
await async_pipeline_from_audio_stream(
self.hass,
context=self.context,
event_callback=handle_pipeline_event,
stt_metadata=stt.SpeechMetadata(
language="",
format=stt.AudioFormats.WAV,
codec=stt.AudioCodecs.PCM,
bit_rate=stt.AudioBitRates.BITRATE_16,
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
),
stt_stream=self._iterate_packets(),
)
bytes_per_sample = stt.AudioBitRates.BITRATE_16 // 8
sample_offset = 0
samples_left = len(audio_bytes) // bytes_per_sample
while samples_left > 0:
bytes_offset = sample_offset * bytes_per_sample
chunk: bytes = audio_bytes[bytes_offset : bytes_offset + 1024]
samples_in_chunk = len(chunk) // bytes_per_sample
samples_left -= samples_in_chunk
self.transport.sendto(chunk, self.remote_addr)
await asyncio.sleep(
samples_in_chunk / stt.AudioSampleRates.SAMPLERATE_16000 * 0.99
)
sample_offset += samples_in_chunk
finally:
self._tts_done.set()
+1 -1
View File
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/eufy",
"iot_class": "local_polling",
"loggers": ["lakeside"],
"requirements": ["lakeside==0.12"]
"requirements": ["lakeside==0.13"]
}
@@ -28,6 +28,15 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
ForecastSolarSensorEntityDescription(
key="energy_production_today_remaining",
name="Estimated energy production - remaining today",
state=lambda estimate: estimate.energy_production_today_remaining,
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
ForecastSolarSensorEntityDescription(
key="energy_production_tomorrow",
name="Estimated energy production - tomorrow",
@@ -34,6 +34,7 @@ async def async_get_config_entry_diagnostics(
},
"data": {
"energy_production_today": coordinator.data.energy_production_today,
"energy_production_today_remaining": coordinator.data.energy_production_today_remaining,
"energy_production_tomorrow": coordinator.data.energy_production_tomorrow,
"energy_current_hour": coordinator.data.energy_current_hour,
"power_production_now": coordinator.data.power_production_now,
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20230411.1"]
"requirements": ["home-assistant-frontend==20230428.0"]
}
@@ -8,7 +8,7 @@ from afsapi import AFSAPI, ConnectionError as FSConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_PIN, CONF_WEBFSAPI_URL, DOMAIN
@@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
await afsapi.get_power()
except FSConnectionError as exception:
raise PlatformNotReady from exception
raise ConfigEntryNotReady from exception
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = afsapi
@@ -25,7 +25,10 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"issues": {
@@ -85,9 +85,12 @@ from .handler import ( # noqa: F401
async_get_addon_discovery_info,
async_get_addon_info,
async_get_addon_store_info,
async_get_yellow_settings,
async_install_addon,
async_reboot_host,
async_restart_addon,
async_set_addon_options,
async_set_yellow_settings,
async_start_addon,
async_stop_addon,
async_uninstall_addon,
@@ -262,6 +262,37 @@ async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> b
return await hassio.send_command(command, timeout=None)
@api_data
async def async_get_yellow_settings(hass: HomeAssistant) -> dict[str, bool]:
"""Return settings specific to Home Assistant Yellow."""
hassio: HassIO = hass.data[DOMAIN]
return await hassio.send_command("/os/boards/yellow", method="get")
@api_data
async def async_set_yellow_settings(
hass: HomeAssistant, settings: dict[str, bool]
) -> dict:
"""Set settings specific to Home Assistant Yellow.
Returns an empty dict.
"""
hassio: HassIO = hass.data[DOMAIN]
return await hassio.send_command(
"/os/boards/yellow", method="post", payload=settings
)
@api_data
async def async_reboot_host(hass: HomeAssistant) -> dict:
"""Reboot the host.
Returns an empty dict.
"""
hassio: HassIO = hass.data[DOMAIN]
return await hassio.send_command("/host/reboot", method="post", timeout=60)
class HassIO:
"""Small API wrapper for Hass.io."""
@@ -156,6 +156,21 @@ class ExposedEntities:
return result
@callback
def async_get_entity_settings(self, entity_id: str) -> dict[str, Mapping[str, Any]]:
"""Get assistant expose settings for an entity."""
entity_registry = er.async_get(self._hass)
result: dict[str, Mapping[str, Any]] = {}
if not (registry_entry := entity_registry.async_get(entity_id)):
raise HomeAssistantError("Unknown entity")
for assistant in KNOWN_ASSISTANTS:
if options := registry_entry.options.get(assistant):
result[assistant] = options
return result
@callback
def async_should_expose(self, assistant: str, entity_id: str) -> bool:
"""Return True if an entity should be exposed to an assistant."""
@@ -348,6 +363,27 @@ def async_get_assistant_settings(
return exposed_entities.async_get_assistant_settings(assistant)
@callback
def async_get_entity_settings(
hass: HomeAssistant, entity_id: str
) -> dict[str, Mapping[str, Any]]:
"""Get assistant expose settings for an entity."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
return exposed_entities.async_get_entity_settings(entity_id)
@callback
def async_expose_entity(
hass: HomeAssistant,
assistant: str,
entity_id: str,
should_expose: bool,
) -> None:
"""Get assistant expose settings for an entity."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_expose_entity(assistant, entity_id, should_expose)
@callback
def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool:
"""Return True if an entity should be exposed to an assistant."""
@@ -1,15 +1,37 @@
"""Config flow for the Home Assistant Yellow integration."""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
import async_timeout
import voluptuous as vol
from homeassistant.components.hassio import (
HassioAPIError,
async_get_yellow_settings,
async_reboot_host,
async_set_yellow_settings,
)
from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from .const import DOMAIN, ZHA_HW_DISCOVERY_DATA
_LOGGER = logging.getLogger(__name__)
STEP_HW_SETTINGS_SCHEMA = vol.Schema(
{
vol.Required("disk_led"): selector.BooleanSelector(),
vol.Required("heartbeat_led"): selector.BooleanSelector(),
vol.Required("power_led"): selector.BooleanSelector(),
}
)
class HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Home Assistant Yellow."""
@@ -35,6 +57,82 @@ class HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN):
class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler):
"""Handle an option flow for Home Assistant Yellow."""
_hw_settings: dict[str, bool] | None = None
async def async_step_on_supervisor(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle logic when on Supervisor host."""
return self.async_show_menu(
step_id="main_menu",
menu_options=[
"hardware_settings",
"multipan_settings",
],
)
async def async_step_hardware_settings(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle hardware settings."""
if user_input is not None:
if self._hw_settings == user_input:
return self.async_create_entry(data={})
try:
async with async_timeout.timeout(10):
await async_set_yellow_settings(self.hass, user_input)
except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err:
_LOGGER.warning("Failed to write hardware settings", exc_info=err)
return self.async_abort(reason="write_hw_settings_error")
return await self.async_step_confirm_reboot()
try:
async with async_timeout.timeout(10):
self._hw_settings: dict[str, bool] = await async_get_yellow_settings(
self.hass
)
except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err:
_LOGGER.warning("Failed to read hardware settings", exc_info=err)
return self.async_abort(reason="read_hw_settings_error")
schema = self.add_suggested_values_to_schema(
STEP_HW_SETTINGS_SCHEMA, self._hw_settings
)
return self.async_show_form(step_id="hardware_settings", data_schema=schema)
async def async_step_confirm_reboot(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm reboot host."""
return self.async_show_menu(
step_id="reboot_menu",
menu_options=[
"reboot_now",
"reboot_later",
],
)
async def async_step_reboot_now(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Reboot now."""
await async_reboot_host(self.hass)
return self.async_create_entry(data={})
async def async_step_reboot_later(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Reboot later."""
return self.async_create_entry(data={})
async def async_step_multipan_settings(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle multipan settings."""
return await super().async_step_on_supervisor(user_input)
async def _async_serial_port_settings(
self,
) -> silabs_multiprotocol_addon.SerialPortSettings:
@@ -11,9 +11,31 @@
"addon_installed_other_device": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]"
},
"hardware_settings": {
"title": "Configure hardware settings",
"data": {
"disk_led": "Disk LED",
"heartbeat_led": "Heartbeat LED",
"power_led": "Power LED"
}
},
"install_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
},
"main_menu": {
"menu_options": {
"hardware_settings": "[%key:component::homeassistant_yellow::options::step::hardware_settings::title%]",
"multipan_settings": "Configure IEEE 802.15.4 radio multiprotocol support"
}
},
"reboot_menu": {
"title": "Reboot required",
"description": "The settings have changed, but the new settings will not take effect until the system is rebooted",
"menu_options": {
"reboot_later": "Reboot manually later",
"reboot_now": "Reboot now"
}
},
"show_revert_guide": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]",
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]"
@@ -31,6 +53,8 @@
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
"read_hw_settings_error": "Failed to read hardware settings",
"write_hw_settings_error": "Failed to write hardware settings",
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
},
"progress": {
+1 -1
View File
@@ -177,7 +177,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
"search": self.config_entry.data[CONF_SEARCH],
"folder": self.config_entry.data[CONF_FOLDER],
"date": message.date,
"text": message.text,
"text": message.text[:2048],
"sender": message.sender,
"subject": message.subject,
"headers": message.headers,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"domain": "imap",
"name": "IMAP",
"codeowners": ["@engrbm87"],
"codeowners": ["@engrbm87", "@jbouwh"],
"config_flow": true,
"dependencies": ["repairs"],
"documentation": "https://www.home-assistant.io/integrations/imap",
@@ -38,11 +38,15 @@ def async_describe_events(
device_type = data[ATTR_TYPE]
leap_button_number = data[ATTR_LEAP_BUTTON_NUMBER]
dr_device_id = data[ATTR_DEVICE_ID]
lutron_data = get_lutron_data_by_dr_id(hass, dr_device_id)
keypad = lutron_data.keypad_data.dr_device_id_to_keypad.get(dr_device_id)
keypad_id = keypad["lutron_device_id"]
rev_button_map: dict[int, str] | None = None
keypad_button_names_to_leap: dict[int, dict[str, int]] = {}
keypad_id: int = -1
keypad_button_names_to_leap = lutron_data.keypad_data.button_names_to_leap
if lutron_data := get_lutron_data_by_dr_id(hass, dr_device_id):
keypad_data = lutron_data.keypad_data
keypad = keypad_data.dr_device_id_to_keypad.get(dr_device_id)
keypad_id = keypad["lutron_device_id"]
keypad_button_names_to_leap = keypad_data.button_names_to_leap
if not (rev_button_map := LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP.get(device_type)):
if fwd_button_map := keypad_button_names_to_leap.get(keypad_id):
@@ -195,6 +195,17 @@ async def async_remove_config_entry_device(
if node is None:
return True
if node.is_bridge_device:
device_registry = dr.async_get(hass)
devices = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
for device in devices:
if device.via_device_id == device_entry.id:
device_registry.async_update_device(
device.id, remove_config_entry_id=config_entry.entry_id
)
matter = get_matter(hass)
await matter.matter_client.remove_node(node.node_id)
+23 -19
View File
@@ -8,6 +8,7 @@ from chip.clusters import Objects as clusters
from homeassistant.components.cover import (
ATTR_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityDescription,
CoverEntityFeature,
@@ -25,6 +26,12 @@ from .models import MatterDiscoverySchema
# The MASK used for extracting bits 0 to 1 of the byte.
OPERATIONAL_STATUS_MASK = 0b11
# map Matter window cover types to HA device class
TYPE_MAP = {
clusters.WindowCovering.Enums.Type.kAwning: CoverDeviceClass.AWNING,
clusters.WindowCovering.Enums.Type.kDrapery: CoverDeviceClass.CURTAIN,
}
class OperationalStatus(IntEnum):
"""Currently ongoing operations enumeration for coverings, as defined in the Matter spec."""
@@ -56,20 +63,6 @@ class MatterCover(MatterEntity, CoverEntity):
| CoverEntityFeature.SET_POSITION
)
@property
def current_cover_position(self) -> int:
"""Return the current position of cover."""
if self._attr_current_cover_position:
current_position = self._attr_current_cover_position
else:
current_position = self.get_matter_attribute_value(
clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage
)
assert current_position is not None
return current_position
@property
def is_closed(self) -> bool:
"""Return true if cover is closed, else False."""
@@ -91,7 +84,8 @@ class MatterCover(MatterEntity, CoverEntity):
"""Set the cover to a specific position."""
position = kwargs[ATTR_POSITION]
await self.send_device_command(
clusters.WindowCovering.Commands.GoToLiftValue(position)
# value needs to be inverted and is sent in 100ths
clusters.WindowCovering.Commands.GoToLiftPercentage((100 - position) * 100)
)
async def send_device_command(self, command: Any) -> None:
@@ -129,15 +123,25 @@ class MatterCover(MatterEntity, CoverEntity):
self._attr_is_opening = False
self._attr_is_closing = False
self._attr_current_cover_position = self.get_matter_attribute_value(
# current position is inverted in matter (100 is closed, 0 is open)
current_cover_position = self.get_matter_attribute_value(
clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage
)
self._attr_current_cover_position = 100 - current_cover_position
LOGGER.debug(
"Current position: %s for %s",
self._attr_current_cover_position,
"Current position for %s - raw: %s - corrected: %s",
self.entity_id,
current_cover_position,
self.current_cover_position,
)
# map matter type to HA deviceclass
device_type: clusters.WindowCovering.Enums.Type = (
self.get_matter_attribute_value(clusters.WindowCovering.Attributes.Type)
)
self._attr_device_class = TYPE_MAP.get(device_type, CoverDeviceClass.AWNING)
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
@@ -149,5 +153,5 @@ DISCOVERY_SCHEMAS = [
clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage,
clusters.WindowCovering.Attributes.OperationalStatus,
),
),
)
]
+3
View File
@@ -740,6 +740,9 @@ class MQTT:
asyncio.run_coroutine_threadsafe(
publish_birth_message(birth_message), self.hass.loop
)
else:
# Update subscribe cooldown period to a shorter time
self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN)
async def _async_resubscribe(self) -> None:
"""Resubscribe on reconnect."""
@@ -31,8 +31,8 @@
},
"issues": {
"deprecated_yaml": {
"title": "The Netxcloud YAML configuration has been deprecated",
"description": "Configuring Netxcloud using YAML has been deprecated.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `nextcloud` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
"title": "The Nextcloud YAML configuration has been deprecated",
"description": "Configuring Nextcloud using YAML has been deprecated.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `nextcloud` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
}
}
}
@@ -7,7 +7,7 @@
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": [
"sqlalchemy==2.0.10",
"sqlalchemy==2.0.11",
"fnv-hash-fast==0.3.1",
"psutil-home-assistant==0.0.1"
]
+54 -7
View File
@@ -34,6 +34,7 @@ from .queries import (
find_event_types_to_purge,
find_events_to_purge,
find_latest_statistics_runs_run_id,
find_legacy_detached_states_and_attributes_to_purge,
find_legacy_event_state_and_attributes_and_data_ids_to_purge,
find_legacy_row,
find_short_term_statistics_to_purge,
@@ -146,7 +147,28 @@ def _purge_legacy_format(
_purge_unused_attributes_ids(instance, session, attributes_ids)
_purge_event_ids(session, event_ids)
_purge_unused_data_ids(instance, session, data_ids)
return bool(event_ids or state_ids or attributes_ids or data_ids)
# The database may still have some rows that have an event_id but are not
# linked to any event. These rows are not linked to any event because the
# event was deleted. We need to purge these rows as well or we will never
# switch to the new format which will prevent us from purging any events
# that happened after the detached states.
(
detached_state_ids,
detached_attributes_ids,
) = _select_legacy_detached_state_and_attributes_and_data_ids_to_purge(
session, purge_before
)
_purge_state_ids(instance, session, detached_state_ids)
_purge_unused_attributes_ids(instance, session, detached_attributes_ids)
return bool(
event_ids
or state_ids
or attributes_ids
or data_ids
or detached_state_ids
or detached_attributes_ids
)
def _purge_states_and_attributes_ids(
@@ -412,6 +434,31 @@ def _select_short_term_statistics_to_purge(
return [statistic.id for statistic in statistics]
def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge(
session: Session, purge_before: datetime
) -> tuple[set[int], set[int]]:
"""Return a list of state, and attribute ids to purge.
We do not link these anymore since state_change events
do not exist in the events table anymore, however we
still need to be able to purge them.
"""
states = session.execute(
find_legacy_detached_states_and_attributes_to_purge(
dt_util.utc_to_timestamp(purge_before)
)
).all()
_LOGGER.debug("Selected %s state ids to remove", len(states))
state_ids = set()
attributes_ids = set()
for state in states:
if state_id := state.state_id:
state_ids.add(state_id)
if attributes_id := state.attributes_id:
attributes_ids.add(attributes_id)
return state_ids, attributes_ids
def _select_legacy_event_state_and_attributes_and_data_ids_to_purge(
session: Session, purge_before: datetime
) -> tuple[set[int], set[int], set[int], set[int]]:
@@ -433,12 +480,12 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge(
data_ids = set()
for event in events:
event_ids.add(event.event_id)
if event.state_id:
state_ids.add(event.state_id)
if event.attributes_id:
attributes_ids.add(event.attributes_id)
if event.data_id:
data_ids.add(event.data_id)
if state_id := event.state_id:
state_ids.add(state_id)
if attributes_id := event.attributes_id:
attributes_ids.add(attributes_id)
if data_id := event.data_id:
data_ids.add(data_id)
return event_ids, state_ids, attributes_ids, data_ids
@@ -678,6 +678,22 @@ def find_legacy_event_state_and_attributes_and_data_ids_to_purge(
)
def find_legacy_detached_states_and_attributes_to_purge(
purge_before: float,
) -> StatementLambdaElement:
"""Find states rows with event_id set but not linked event_id in Events."""
return lambda_stmt(
lambda: select(States.state_id, States.attributes_id)
.outerjoin(Events, States.event_id == Events.event_id)
.filter(States.event_id.isnot(None))
.filter(
(States.last_updated_ts < purge_before) | States.last_updated_ts.is_(None)
)
.filter(Events.event_id.is_(None))
.limit(SQLITE_MAX_BIND_VARS)
)
def find_legacy_row() -> StatementLambdaElement:
"""Check if there are still states in the table with an event_id."""
# https://github.com/sqlalchemy/sqlalchemy/issues/9189
@@ -6,7 +6,13 @@ from typing import Any
from roborock.api import RoborockApiClient
from roborock.containers import UserData
from roborock.exceptions import RoborockException
from roborock.exceptions import (
RoborockAccountDoesNotExist,
RoborockException,
RoborockInvalidCode,
RoborockInvalidEmail,
RoborockUrlException,
)
import voluptuous as vol
from homeassistant import config_entries
@@ -43,9 +49,15 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self._client = RoborockApiClient(username)
try:
await self._client.request_code()
except RoborockAccountDoesNotExist:
errors["base"] = "invalid_email"
except RoborockUrlException:
errors["base"] = "unknown_url"
except RoborockInvalidEmail:
errors["base"] = "invalid_email_format"
except RoborockException as ex:
_LOGGER.exception(ex)
errors["base"] = "invalid_email"
errors["base"] = "unknown_roborock"
except Exception as ex: # pylint: disable=broad-except
_LOGGER.exception(ex)
errors["base"] = "unknown"
@@ -70,9 +82,11 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Logging into Roborock account using email provided code")
try:
login_data = await self._client.code_login(code)
except RoborockInvalidCode:
errors["base"] = "invalid_code"
except RoborockException as ex:
_LOGGER.exception(ex)
errors["base"] = "invalid_code"
errors["base"] = "unknown_roborock"
except Exception as ex: # pylint: disable=broad-except
_LOGGER.exception(ex)
errors["base"] = "unknown"
@@ -13,7 +13,7 @@ from roborock.containers import (
)
from roborock.exceptions import RoborockException
from roborock.local_api import RoborockLocalClient
from roborock.typing import RoborockDeviceProp
from roborock.typing import DeviceProp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -26,9 +26,7 @@ SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
class RoborockDataUpdateCoordinator(
DataUpdateCoordinator[dict[str, RoborockDeviceProp]]
):
class RoborockDataUpdateCoordinator(DataUpdateCoordinator[dict[str, DeviceProp]]):
"""Class to manage fetching data from the API."""
def __init__(
@@ -50,7 +48,7 @@ class RoborockDataUpdateCoordinator(
device,
networking,
product_info[device.product_id],
RoborockDeviceProp(),
DeviceProp(),
)
local_devices_info[device.duid] = RoborockLocalDeviceInfo(
device, networking
@@ -71,7 +69,7 @@ class RoborockDataUpdateCoordinator(
else:
device_info.props = device_prop
async def _async_update_data(self) -> dict[str, RoborockDeviceProp]:
async def _async_update_data(self) -> dict[str, DeviceProp]:
"""Update data via library."""
try:
await asyncio.gather(
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/roborock",
"iot_class": "local_polling",
"loggers": ["roborock"],
"requirements": ["python-roborock==0.6.5"]
"requirements": ["python-roborock==0.8.3"]
}
+2 -2
View File
@@ -2,7 +2,7 @@
from dataclasses import dataclass
from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo
from roborock.typing import RoborockDeviceProp
from roborock.typing import DeviceProp
@dataclass
@@ -12,4 +12,4 @@ class RoborockHassDeviceInfo:
device: HomeDataDevice
network_info: NetworkInfo
product: HomeDataProduct
props: RoborockDeviceProp
props: DeviceProp
@@ -17,6 +17,9 @@
"error": {
"invalid_code": "The code you entered was incorrect, please check it and try again.",
"invalid_email": "There is no account associated with the email you entered, please try again.",
"invalid_email_format": "There is an issue with the formatting of your email - please try again.",
"unknown_roborock": "There was an unknown roborock exception - please check your logs.",
"unknown_url": "There was an issue determining the correct url for your roborock account - please check your logs.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
+11 -11
View File
@@ -506,13 +506,23 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
If media_type is "playlist", media_id should be a Sonos
Playlist name. Otherwise, media_id should be a URI.
"""
is_radio = False
if media_source.is_media_source_id(media_id):
is_radio = media_id.startswith("media-source://radio_browser/")
media_type = MediaType.MUSIC
media = await media_source.async_resolve_media(
self.hass, media_id, self.entity_id
)
media_id = async_process_play_media_url(self.hass, media.url)
if kwargs.get(ATTR_MEDIA_ANNOUNCE):
volume = kwargs.get("extra", {}).get("volume")
_LOGGER.debug("Playing %s using websocket audioclip", media_id)
try:
assert self.speaker.websocket
response, _ = await self.speaker.websocket.play_clip(
media_id,
async_process_play_media_url(self.hass, media_id),
volume=volume,
)
except SonosWebsocketError as exc:
@@ -526,16 +536,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
media_type = spotify.resolve_spotify_media_type(media_type)
media_id = spotify.spotify_uri_from_media_browser_url(media_id)
is_radio = False
if media_source.is_media_source_id(media_id):
is_radio = media_id.startswith("media-source://radio_browser/")
media_type = MediaType.MUSIC
media = await media_source.async_resolve_media(
self.hass, media_id, self.entity_id
)
media_id = media.url
await self.hass.async_add_executor_job(
partial(self._play_media, media_type, media_id, is_radio, **kwargs)
)
+1 -1
View File
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sql",
"iot_class": "local_polling",
"requirements": ["sqlalchemy==2.0.10"]
"requirements": ["sqlalchemy==2.0.11"]
}
@@ -5,6 +5,7 @@ import logging
import voluptuous as vol
from homeassistant.components.homeassistant import exposed_entities
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import Event, HomeAssistant, callback
@@ -104,17 +105,39 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Unload a config entry."""
# Unhide the wrapped entry if registered
"""Unload a config entry.
This will unhide the wrapped entity and restore assistant expose settings.
"""
registry = er.async_get(hass)
try:
entity_id = er.async_validate_entity_id(registry, entry.options[CONF_ENTITY_ID])
switch_entity_id = er.async_validate_entity_id(
registry, entry.options[CONF_ENTITY_ID]
)
except vol.Invalid:
# The source entity has been removed from the entity registry
return
if not (entity_entry := registry.async_get(entity_id)):
if not (switch_entity_entry := registry.async_get(switch_entity_id)):
return
if entity_entry.hidden_by == er.RegistryEntryHider.INTEGRATION:
registry.async_update_entity(entity_id, hidden_by=None)
# Unhide the wrapped entity
if switch_entity_entry.hidden_by == er.RegistryEntryHider.INTEGRATION:
registry.async_update_entity(switch_entity_id, hidden_by=None)
switch_as_x_entries = er.async_entries_for_config_entry(registry, entry.entry_id)
if not switch_as_x_entries:
return
switch_as_x_entry = switch_as_x_entries[0]
# Restore assistant expose settings
expose_settings = exposed_entities.async_get_entity_settings(
hass, switch_as_x_entry.entity_id
)
for assistant, settings in expose_settings.items():
if (should_expose := settings.get("should_expose")) is None:
continue
exposed_entities.async_expose_entity(
hass, assistant, switch_entity_id, should_expose
)
+29 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -99,14 +100,37 @@ class BaseEntity(Entity):
{"entity_id": self._switch_entity_id},
)
if not self._is_new_entity:
if not self._is_new_entity or not (
wrapped_switch := registry.async_get(self._switch_entity_id)
):
return
wrapped_switch = registry.async_get(self._switch_entity_id)
if not wrapped_switch or wrapped_switch.name is None:
return
def copy_custom_name(wrapped_switch: er.RegistryEntry) -> None:
"""Copy the name set by user from the wrapped entity."""
if wrapped_switch.name is None:
return
registry.async_update_entity(self.entity_id, name=wrapped_switch.name)
registry.async_update_entity(self.entity_id, name=wrapped_switch.name)
def copy_expose_settings() -> None:
"""Copy assistant expose settings from the wrapped entity.
Also unexpose the wrapped entity if exposed.
"""
expose_settings = exposed_entities.async_get_entity_settings(
self.hass, self._switch_entity_id
)
for assistant, settings in expose_settings.items():
if (should_expose := settings.get("should_expose")) is None:
continue
exposed_entities.async_expose_entity(
self.hass, assistant, self.entity_id, should_expose
)
exposed_entities.async_expose_entity(
self.hass, assistant, self._switch_entity_id, False
)
copy_custom_name(wrapped_switch)
copy_expose_settings()
class BaseToggleEntity(BaseEntity, ToggleEntity):
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
from ipaddress import ip_address
from ipaddress import ip_address as ip
import logging
from typing import Any, cast
from urllib.parse import urlparse
@@ -38,6 +38,7 @@ from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.util.network import is_ip_address as is_ip
from .const import (
CONF_DEVICE_TOKEN,
@@ -99,14 +100,6 @@ def _ordered_shared_schema(
}
def _is_valid_ip(text: str) -> bool:
try:
ip_address(text)
except ValueError:
return False
return True
def format_synology_mac(mac: str) -> str:
"""Format a mac address to the format used by Synology DSM."""
return mac.replace(":", "").replace("-", "").upper()
@@ -284,16 +277,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
break
self._abort_if_unique_id_configured()
fqdn_with_ssl_verification = (
existing_entry
and not _is_valid_ip(existing_entry.data[CONF_HOST])
and existing_entry.data[CONF_VERIFY_SSL]
)
if (
existing_entry
and is_ip(existing_entry.data[CONF_HOST])
and is_ip(host)
and existing_entry.data[CONF_HOST] != host
and not fqdn_with_ssl_verification
and ip(existing_entry.data[CONF_HOST]).version == ip(host).version
):
_LOGGER.info(
"Update host from '%s' to '%s' for NAS '%s' via discovery",
@@ -26,12 +26,6 @@ remove_torrent:
selector:
config_entry:
integration: transmission
name:
name: Name
description: Instance name as entered during entry config
example: Transmission
selector:
text:
id:
name: ID
description: ID of a torrent
@@ -56,12 +50,6 @@ start_torrent:
selector:
config_entry:
integration: transmission
name:
name: Name
description: Instance name as entered during entry config
example: Transmission
selector:
text:
id:
name: ID
description: ID of a torrent
@@ -79,12 +67,6 @@ stop_torrent:
selector:
config_entry:
integration: transmission
name:
name: Name
description: Instance name as entered during entry config
example: Transmission
selector:
text:
id:
name: ID
description: ID of a torrent
+2 -2
View File
@@ -4,10 +4,10 @@
"codeowners": ["@raman325"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/vizio",
"integration_type": "hub",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyvizio"],
"quality_scale": "platinum",
"requirements": ["pyvizio==0.1.60"],
"requirements": ["pyvizio==0.1.61"],
"zeroconf": ["_viziocast._tcp.local."]
}
@@ -31,19 +31,19 @@ async def async_setup_entry(
@callback
def async_add_device(device: VoIPDevice) -> None:
"""Add device."""
async_add_entities([VoIPCallActive(device)])
async_add_entities([VoIPCallInProgress(device)])
domain_data.devices.async_add_new_device_listener(async_add_device)
async_add_entities([VoIPCallActive(device) for device in domain_data.devices])
async_add_entities([VoIPCallInProgress(device) for device in domain_data.devices])
class VoIPCallActive(VoIPEntity, BinarySensorEntity):
"""Entity to represent voip is allowed."""
class VoIPCallInProgress(VoIPEntity, BinarySensorEntity):
"""Entity to represent voip call is in progress."""
entity_description = BinarySensorEntityDescription(
key="call_active",
translation_key="call_active",
key="call_in_progress",
translation_key="call_in_progress",
)
_attr_is_on = False
+3 -3
View File
@@ -11,13 +11,13 @@
},
"entity": {
"binary_sensor": {
"call_active": {
"name": "Call Active"
"call_in_progress": {
"name": "Call in progress"
}
},
"switch": {
"allow_call": {
"name": "Allow Calls"
"name": "Allow calls"
}
},
"select": {
@@ -281,13 +281,13 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry):
else:
return self.async_create_entry(data=combined_input)
saved_options = self.options.copy()
if saved_options[CONF_PROVINCE] is None:
saved_options[CONF_PROVINCE] = NONE_SENTINEL
schema: vol.Schema = await self.hass.async_add_executor_job(
add_province_to_schema, DATA_SCHEMA_OPT, self.options
)
new_schema = self.add_suggested_values_to_schema(schema, user_input)
new_schema = self.add_suggested_values_to_schema(
schema, user_input or self.options
)
return self.async_show_form(
step_id="init",
@@ -69,6 +69,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
self._hassio_discovery = discovery_info
self.context.update(
{
"title_placeholders": {"name": discovery_info.name},
"configuration_url": f"homeassistant://hassio/addon/{discovery_info.slug}/info",
}
)
return await self.async_step_hassio_confirm()
async def async_step_hassio_confirm(
@@ -80,7 +86,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None:
uri = urlparse(self._hassio_discovery.config["uri"])
if service := await WyomingService.create(uri.hostname, uri.port):
if not any(asr for asr in service.info.asr if asr.installed):
if not any(
asr for asr in service.info.asr if asr.installed
) and not any(tts for tts in service.info.tts if tts.installed):
return self.async_abort(reason="no_services")
return self.async_create_entry(
+22 -1
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
from zigpy.backups import NetworkBackup
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
from zigpy.types import Channels
from zigpy.util import pick_optimal_channel
from .core.const import (
CONF_RADIO_TYPE,
@@ -111,3 +113,22 @@ def async_get_radio_path(
config_entry = _get_config_entry(hass)
return config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
async def async_change_channel(
hass: HomeAssistant, new_channel: int | Literal["auto"]
) -> None:
"""Migrate the ZHA network to a new channel."""
zha_gateway: ZHAGateway = _get_gateway(hass)
app = zha_gateway.application_controller
if new_channel == "auto":
channel_energy = await app.energy_scan(
channels=Channels.ALL_CHANNELS,
duration_exp=4,
count=1,
)
new_channel = pick_optimal_channel(channel_energy)
await app.move_network_to_channel(new_channel)
@@ -22,6 +22,7 @@ from .core import discovery
from .core.const import (
CLUSTER_HANDLER_ACCELEROMETER,
CLUSTER_HANDLER_BINARY_INPUT,
CLUSTER_HANDLER_HUE_OCCUPANCY,
CLUSTER_HANDLER_OCCUPANCY,
CLUSTER_HANDLER_ON_OFF,
CLUSTER_HANDLER_ZONE,
@@ -130,6 +131,11 @@ class Occupancy(BinarySensor):
_attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY)
class HueOccupancy(Occupancy):
"""ZHA Hue occupancy."""
@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF)
class Opening(BinarySensor):
"""ZHA OnOff BinarySensor."""
+28 -4
View File
@@ -32,7 +32,11 @@ from .core.const import (
DOMAIN,
RadioType,
)
from .radio_manager import HARDWARE_DISCOVERY_SCHEMA, ZhaRadioManager
from .radio_manager import (
HARDWARE_DISCOVERY_SCHEMA,
RECOMMENDED_RADIOS,
ZhaRadioManager,
)
CONF_MANUAL_PATH = "Enter Manually"
SUPPORTED_PORT_SETTINGS = (
@@ -192,7 +196,7 @@ class BaseZhaFlow(FlowHandler):
else ""
)
return await self.async_step_choose_formation_strategy()
return await self.async_step_verify_radio()
# Pre-select the currently configured port
default_port = vol.UNDEFINED
@@ -252,7 +256,7 @@ class BaseZhaFlow(FlowHandler):
self._radio_mgr.device_settings = user_input.copy()
if await self._radio_mgr.radio_type.controller.probe(user_input):
return await self.async_step_choose_formation_strategy()
return await self.async_step_verify_radio()
errors["base"] = "cannot_connect"
@@ -289,6 +293,26 @@ class BaseZhaFlow(FlowHandler):
errors=errors,
)
async def async_step_verify_radio(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Add a warning step to dissuade the use of deprecated radios."""
assert self._radio_mgr.radio_type is not None
# Skip this step if we are using a recommended radio
if user_input is not None or self._radio_mgr.radio_type in RECOMMENDED_RADIOS:
return await self.async_step_choose_formation_strategy()
return self.async_show_form(
step_id="verify_radio",
description_placeholders={
CONF_NAME: self._radio_mgr.radio_type.description,
"docs_recommended_adapters_url": (
"https://www.home-assistant.io/integrations/zha/#recommended-zigbee-radio-adapters-and-modules"
),
},
)
async def async_step_choose_formation_strategy(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
@@ -516,7 +540,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN
if self._radio_mgr.device_settings is None:
return await self.async_step_manual_port_config()
return await self.async_step_choose_formation_strategy()
return await self.async_step_verify_radio()
return self.async_show_form(
step_id="confirm",
@@ -424,13 +424,13 @@ class ClusterHandler(LogMixin):
else:
raise TypeError(f"Unexpected zha_send_event {command!r} argument: {arg!r}")
self._endpoint.device.zha_send_event(
self._endpoint.send_event(
{
ATTR_UNIQUE_ID: self.unique_id,
ATTR_CLUSTER_ID: self.cluster.cluster_id,
ATTR_COMMAND: command,
# Maintain backwards compatibility with the old zigpy response format
ATTR_ARGS: args, # type: ignore[dict-item]
ATTR_ARGS: args,
ATTR_PARAMS: params,
}
)
@@ -347,7 +347,7 @@ class OnOffClientClusterHandler(ClientClusterHandler):
class OnOffClusterHandler(ClusterHandler):
"""Cluster handler for the OnOff Zigbee cluster."""
ON_OFF = 0
ON_OFF = general.OnOff.attributes_by_name["on_off"].id
REPORT_CONFIG = (AttrReportConfig(attr="on_off", config=REPORT_CONFIG_IMMEDIATE),)
ZCL_INIT_ATTRS = {
"start_up_on_off": True,
@@ -374,6 +374,15 @@ class OnOffClusterHandler(ClusterHandler):
if self.cluster.endpoint.model == "TS011F":
self.ZCL_INIT_ATTRS["child_lock"] = True
@classmethod
def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool:
"""Filter the cluster match for specific devices."""
return not (
cluster.endpoint.device.manufacturer == "Konke"
and cluster.endpoint.device.model
in ("3AFE280100510001", "3AFE170100510001")
)
@property
def on_off(self) -> bool | None:
"""Return cached value of on/off attribute."""
+4 -1
View File
@@ -78,6 +78,7 @@ CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT = "electrical_measurement"
CLUSTER_HANDLER_EVENT_RELAY = "event_relay"
CLUSTER_HANDLER_FAN = "fan"
CLUSTER_HANDLER_HUMIDITY = "humidity"
CLUSTER_HANDLER_HUE_OCCUPANCY = "philips_occupancy"
CLUSTER_HANDLER_SOIL_MOISTURE = "soil_moisture"
CLUSTER_HANDLER_LEAF_WETNESS = "leaf_wetness"
CLUSTER_HANDLER_IAS_ACE = "ias_ace"
@@ -151,7 +152,9 @@ CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY = 60 * 60 * 6 # 6 hours
CONF_ZHA_OPTIONS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): cv.positive_int,
vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): vol.All(
vol.Coerce(float), vol.Range(min=0, max=2**16 / 10)
),
vol.Required(CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, default=False): cv.boolean,
vol.Required(CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, default=True): cv.boolean,
vol.Required(CONF_ALWAYS_PREFER_XY_COLOR_MODE, default=True): cv.boolean,
@@ -205,11 +205,13 @@ class Endpoint:
def send_event(self, signal: dict[str, Any]) -> None:
"""Broadcast an event from this endpoint."""
signal["endpoint"] = {
"id": self.id,
"unique_id": self.unique_id,
}
self.device.zha_send_event(signal)
self.device.zha_send_event(
{
const.ATTR_UNIQUE_ID: self.unique_id,
const.ATTR_ENDPOINT_ID: self.id,
**signal,
}
)
def claim_cluster_handlers(self, cluster_handlers: list[ClusterHandler]) -> None:
"""Claim cluster handlers."""
+27 -21
View File
@@ -113,7 +113,7 @@ class BaseLight(LogMixin, light.LightEntity):
"""Operations common to all light entities."""
_FORCE_ON = False
_DEFAULT_MIN_TRANSITION_TIME = 0
_DEFAULT_MIN_TRANSITION_TIME: float = 0
def __init__(self, *args, **kwargs):
"""Initialize the light."""
@@ -181,9 +181,7 @@ class BaseLight(LogMixin, light.LightEntity):
"""Turn the entity on."""
transition = kwargs.get(light.ATTR_TRANSITION)
duration = (
transition * 10
if transition is not None
else self._zha_config_transition * 10
transition if transition is not None else self._zha_config_transition
) or (
# if 0 is passed in some devices still need the minimum default
self._DEFAULT_MIN_TRANSITION_TIME
@@ -210,7 +208,7 @@ class BaseLight(LogMixin, light.LightEntity):
) and self._zha_config_enable_light_transitioning_flag
transition_time = (
(
duration / 10 + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT
duration + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT
if (
(brightness is not None or transition is not None)
and brightness_supported(self._attr_supported_color_modes)
@@ -297,7 +295,7 @@ class BaseLight(LogMixin, light.LightEntity):
# After that, we set it to the desired color/temperature with no transition.
result = await self._level_cluster_handler.move_to_level_with_on_off(
level=DEFAULT_MIN_BRIGHTNESS,
transition_time=self._DEFAULT_MIN_TRANSITION_TIME,
transition_time=int(10 * self._DEFAULT_MIN_TRANSITION_TIME),
)
t_log["move_to_level_with_on_off"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
@@ -337,7 +335,7 @@ class BaseLight(LogMixin, light.LightEntity):
):
result = await self._level_cluster_handler.move_to_level_with_on_off(
level=level,
transition_time=duration,
transition_time=int(10 * duration),
)
t_log["move_to_level_with_on_off"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
@@ -390,7 +388,7 @@ class BaseLight(LogMixin, light.LightEntity):
# The light is has the correct color, so we can now transition
# it to the correct brightness level.
result = await self._level_cluster_handler.move_to_level(
level=level, transition_time=duration
level=level, transition_time=int(10 * duration)
)
t_log["move_to_level_if_color"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
@@ -465,7 +463,9 @@ class BaseLight(LogMixin, light.LightEntity):
if transition is not None and supports_level:
result = await self._level_cluster_handler.move_to_level_with_on_off(
level=0,
transition_time=(transition * 10 or self._DEFAULT_MIN_TRANSITION_TIME),
transition_time=int(
10 * (transition or self._DEFAULT_MIN_TRANSITION_TIME)
),
)
else:
result = await self._on_off_cluster_handler.off()
@@ -511,7 +511,7 @@ class BaseLight(LogMixin, light.LightEntity):
if temperature is not None:
result = await self._color_cluster_handler.move_to_color_temp(
color_temp_mireds=temperature,
transition_time=transition_time,
transition_time=int(10 * transition_time),
)
t_log["move_to_color_temp"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
@@ -529,14 +529,14 @@ class BaseLight(LogMixin, light.LightEntity):
result = await self._color_cluster_handler.enhanced_move_to_hue_and_saturation(
enhanced_hue=int(hs_color[0] * 65535 / 360),
saturation=int(hs_color[1] * 2.54),
transition_time=transition_time,
transition_time=int(10 * transition_time),
)
t_log["enhanced_move_to_hue_and_saturation"] = result
else:
result = await self._color_cluster_handler.move_to_hue_and_saturation(
hue=int(hs_color[0] * 254 / 360),
saturation=int(hs_color[1] * 2.54),
transition_time=transition_time,
transition_time=int(10 * transition_time),
)
t_log["move_to_hue_and_saturation"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
@@ -551,7 +551,7 @@ class BaseLight(LogMixin, light.LightEntity):
result = await self._color_cluster_handler.move_to_color(
color_x=int(xy_color[0] * 65535),
color_y=int(xy_color[1] * 65535),
transition_time=transition_time,
transition_time=int(10 * transition_time),
)
t_log["move_to_color"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
@@ -1091,7 +1091,9 @@ class MinTransitionLight(Light):
"""Representation of a light which does not react to any "move to" calls with 0 as a transition."""
_attr_name: str = "Light"
_DEFAULT_MIN_TRANSITION_TIME = 1
# Transitions are counted in 1/10th of a second increments, so this is the smallest
_DEFAULT_MIN_TRANSITION_TIME = 0.1
@GROUP_MATCH()
@@ -1111,10 +1113,18 @@ class LightGroup(BaseLight, ZhaGroupEntity):
group = self.zha_device.gateway.get_group(self._group_id)
self._GROUP_SUPPORTS_EXECUTE_IF_OFF = True # pylint: disable=invalid-name
# Check all group members to see if they support execute_if_off.
# If at least one member has a color cluster and doesn't support it,
# it's not used.
for member in group.members:
# Ensure we do not send group commands that violate the minimum transition
# time of any members.
if member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS:
self._DEFAULT_MIN_TRANSITION_TIME = ( # pylint: disable=invalid-name
MinTransitionLight._DEFAULT_MIN_TRANSITION_TIME
)
# Check all group members to see if they support execute_if_off.
# If at least one member has a color cluster and doesn't support it,
# it's not used.
for endpoint in member.device._endpoints.values():
for cluster_handler in endpoint.all_cluster_handlers.values():
if (
@@ -1124,10 +1134,6 @@ class LightGroup(BaseLight, ZhaGroupEntity):
self._GROUP_SUPPORTS_EXECUTE_IF_OFF = False
break
self._DEFAULT_MIN_TRANSITION_TIME = any( # pylint: disable=invalid-name
member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS
for member in group.members
)
self._on_off_cluster_handler = group.endpoint[OnOff.cluster_id]
self._level_cluster_handler = group.endpoint[LevelControl.cluster_id]
self._color_cluster_handler = group.endpoint[Color.cluster_id]
+2 -2
View File
@@ -20,10 +20,10 @@
"zigpy_znp"
],
"requirements": [
"bellows==0.35.1",
"bellows==0.35.2",
"pyserial==3.5",
"pyserial-asyncio==0.6",
"zha-quirks==0.0.97",
"zha-quirks==0.0.98",
"zigpy-deconz==0.21.0",
"zigpy==0.55.0",
"zigpy-xbee==0.18.0",
@@ -40,6 +40,12 @@ AUTOPROBE_RADIOS = (
RadioType.zigate,
)
RECOMMENDED_RADIOS = (
RadioType.ezsp,
RadioType.znp,
RadioType.deconz,
)
CONNECT_DELAY_S = 1.0
MIGRATION_RETRIES = 100
+3 -3
View File
@@ -20,9 +20,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .core import discovery
from .core.const import (
CLUSTER_HANDLER_HUE_OCCUPANCY,
CLUSTER_HANDLER_IAS_WD,
CLUSTER_HANDLER_INOVELLI,
CLUSTER_HANDLER_OCCUPANCY,
CLUSTER_HANDLER_ON_OFF,
DATA_ZHA,
SIGNAL_ADD_ENTITIES,
@@ -367,7 +367,7 @@ class HueV1MotionSensitivities(types.enum8):
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY,
cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY,
manufacturers={"Philips", "Signify Netherlands B.V."},
models={"SML001"},
)
@@ -390,7 +390,7 @@ class HueV2MotionSensitivities(types.enum8):
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY,
cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY,
manufacturers={"Philips", "Signify Netherlands B.V."},
models={"SML002", "SML003", "SML004"},
)
@@ -27,6 +27,10 @@
"flow_control": "data flow control"
}
},
"verify_radio": {
"title": "Radio is not recommended",
"description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})."
},
"choose_formation_strategy": {
"title": "Network Formation",
"description": "Choose the network settings for your radio.",
@@ -116,6 +120,10 @@
"flow_control": "[%key:component::zha::config::step::manual_port_config::data::flow_control%]"
}
},
"verify_radio": {
"title": "[%key:component::zha::config::step::verify_radio::title%]",
"description": "[%key:component::zha::config::step::verify_radio::description%]"
},
"choose_formation_strategy": {
"title": "[%key:component::zha::config::step::choose_formation_strategy::title%]",
"description": "[%key:component::zha::config::step::choose_formation_strategy::description%]",
+25 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, cast
import voluptuous as vol
import zigpy.backups
@@ -19,7 +19,11 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.service import async_register_admin_service
from .api import async_get_active_network_settings, async_get_radio_type
from .api import (
async_change_channel,
async_get_active_network_settings,
async_get_radio_type,
)
from .core.const import (
ATTR_ARGS,
ATTR_ATTRIBUTE,
@@ -93,6 +97,7 @@ ATTR_DURATION = "duration"
ATTR_GROUP = "group"
ATTR_IEEE_ADDRESS = "ieee_address"
ATTR_INSTALL_CODE = "install_code"
ATTR_NEW_CHANNEL = "new_channel"
ATTR_SOURCE_IEEE = "source_ieee"
ATTR_TARGET_IEEE = "target_ieee"
ATTR_QR_CODE = "qr_code"
@@ -1204,6 +1209,23 @@ async def websocket_restore_network_backup(
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zha/network/change_channel",
vol.Required(ATTR_NEW_CHANNEL): vol.Any("auto", vol.Range(11, 26)),
}
)
@websocket_api.async_response
async def websocket_change_channel(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Migrate the Zigbee network to a new channel."""
new_channel = cast(Literal["auto"] | int, msg[ATTR_NEW_CHANNEL])
await async_change_channel(hass, new_channel=new_channel)
connection.send_result(msg[ID])
@callback
def async_load_api(hass: HomeAssistant) -> None:
"""Set up the web socket API."""
@@ -1527,6 +1549,7 @@ def async_load_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_list_network_backups)
websocket_api.async_register_command(hass, websocket_create_network_backup)
websocket_api.async_register_command(hass, websocket_restore_network_backup)
websocket_api.async_register_command(hass, websocket_change_channel)
@callback
+1 -1
View File
@@ -8,7 +8,7 @@ from .backports.enum import StrEnum
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2023
MINOR_VERSION: Final = 5
PATCH_VERSION: Final = "0.dev0"
PATCH_VERSION: Final = "0b2"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0)
+1 -1
View File
@@ -6059,7 +6059,7 @@
},
"vizio": {
"name": "VIZIO SmartCast",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
+56
View File
@@ -286,6 +286,28 @@ class AreaSelector(Selector[AreaSelectorConfig]):
return [vol.Schema(str)(val) for val in data]
class AssistPipelineSelectorConfig(TypedDict, total=False):
"""Class to represent an assist pipeline selector config."""
@SELECTORS.register("assist_pipeline")
class AssistPipelineSelector(Selector[AssistPipelineSelectorConfig]):
"""Selector for an assist pipeline."""
selector_type = "assist_pipeline"
CONFIG_SCHEMA = vol.Schema({})
def __init__(self, config: AssistPipelineSelectorConfig) -> None:
"""Instantiate a selector."""
super().__init__(config)
def __call__(self, data: Any) -> str:
"""Validate the passed selection."""
pipeline: str = vol.Schema(str)(data)
return pipeline
class AttributeSelectorConfig(TypedDict, total=False):
"""Class to represent an attribute selector config."""
@@ -659,6 +681,40 @@ class IconSelector(Selector[IconSelectorConfig]):
return icon
class LanguageSelectorConfig(TypedDict, total=False):
"""Class to represent an language selector config."""
languages: list[str]
native_name: bool
no_sort: bool
@SELECTORS.register("language")
class LanguageSelector(Selector[LanguageSelectorConfig]):
"""Selector for an language."""
selector_type = "language"
CONFIG_SCHEMA = vol.Schema(
{
vol.Optional("languages"): [str],
vol.Optional("native_name", default=False): cv.boolean,
vol.Optional("no_sort", default=False): cv.boolean,
}
)
def __init__(self, config: LanguageSelectorConfig) -> None:
"""Instantiate a selector."""
super().__init__(config)
def __call__(self, data: Any) -> str:
"""Validate the passed selection."""
language: str = vol.Schema(str)(data)
if "languages" in self.config and language not in self.config["languages"]:
raise vol.Invalid(f"Value {language} is not a valid option")
return language
class LocationSelectorConfig(TypedDict, total=False):
"""Class to represent a location selector config."""
+4 -4
View File
@@ -25,8 +25,8 @@ ha-av==10.0.0
hass-nabucasa==0.66.2
hassil==1.0.6
home-assistant-bluetooth==1.10.0
home-assistant-frontend==20230411.1
home-assistant-intents==2023.4.17-1
home-assistant-frontend==20230428.0
home-assistant-intents==2023.4.26
httpx==0.24.0
ifaddr==0.1.7
janus==1.0.0
@@ -45,13 +45,13 @@ pyudev==0.23.2
pyyaml==6.0
requests==2.28.2
scapy==2.5.0
sqlalchemy==2.0.10
sqlalchemy==2.0.11
typing-extensions>=4.5.0,<5.0
ulid-transform==0.7.0
voluptuous-serialize==2.6.0
voluptuous==0.13.1
webrtcvad==2.0.10
yarl==1.9.1
yarl==1.9.2
zeroconf==0.58.2
# Constrain pycryptodome to avoid vulnerability
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2023.5.0.dev0"
version = "2023.5.0b2"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
@@ -53,7 +53,7 @@ dependencies = [
"ulid-transform==0.7.0",
"voluptuous==0.13.1",
"voluptuous-serialize==2.6.0",
"yarl==1.9.1",
"yarl==1.9.2",
]
[project.urls]
+1 -1
View File
@@ -27,4 +27,4 @@ typing-extensions>=4.5.0,<5.0
ulid-transform==0.7.0
voluptuous==0.13.1
voluptuous-serialize==2.6.0
yarl==1.9.1
yarl==1.9.2
+8 -8
View File
@@ -428,7 +428,7 @@ beautifulsoup4==4.11.1
# beewi_smartclim==0.0.10
# homeassistant.components.zha
bellows==0.35.1
bellows==0.35.2
# homeassistant.components.bmw_connected_drive
bimmer_connected==0.13.0
@@ -911,10 +911,10 @@ hole==0.8.0
holidays==0.21.13
# homeassistant.components.frontend
home-assistant-frontend==20230411.1
home-assistant-frontend==20230428.0
# homeassistant.components.conversation
home-assistant-intents==2023.4.17-1
home-assistant-intents==2023.4.26
# homeassistant.components.home_connect
homeconnect==0.7.2
@@ -1034,7 +1034,7 @@ krakenex==2.1.0
lacrosse-view==0.0.9
# homeassistant.components.eufy
lakeside==0.12
lakeside==0.13
# homeassistant.components.laundrify
laundrify_aio==1.1.2
@@ -2108,7 +2108,7 @@ python-qbittorrent==0.4.2
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==0.6.5
python-roborock==0.8.3
# homeassistant.components.smarttub
python-smarttub==0.0.33
@@ -2176,7 +2176,7 @@ pyversasense==0.0.6
pyvesync==2.1.1
# homeassistant.components.vizio
pyvizio==0.1.60
pyvizio==0.1.61
# homeassistant.components.velux
pyvlx==0.2.20
@@ -2406,7 +2406,7 @@ spotipy==2.23.0
# homeassistant.components.recorder
# homeassistant.components.sql
sqlalchemy==2.0.10
sqlalchemy==2.0.11
# homeassistant.components.srp_energy
srpenergy==1.3.6
@@ -2718,7 +2718,7 @@ zeroconf==0.58.2
zeversolar==0.3.1
# homeassistant.components.zha
zha-quirks==0.0.97
zha-quirks==0.0.98
# homeassistant.components.zhong_hong
zhong_hong_hvac==1.0.9
+7 -7
View File
@@ -361,7 +361,7 @@ base36==0.1.1
beautifulsoup4==4.11.1
# homeassistant.components.zha
bellows==0.35.1
bellows==0.35.2
# homeassistant.components.bmw_connected_drive
bimmer_connected==0.13.0
@@ -700,10 +700,10 @@ hole==0.8.0
holidays==0.21.13
# homeassistant.components.frontend
home-assistant-frontend==20230411.1
home-assistant-frontend==20230428.0
# homeassistant.components.conversation
home-assistant-intents==2023.4.17-1
home-assistant-intents==2023.4.26
# homeassistant.components.home_connect
homeconnect==0.7.2
@@ -1516,7 +1516,7 @@ python-picnic-api==1.1.0
python-qbittorrent==0.4.2
# homeassistant.components.roborock
python-roborock==0.6.5
python-roborock==0.8.3
# homeassistant.components.smarttub
python-smarttub==0.0.33
@@ -1566,7 +1566,7 @@ pyvera==0.3.13
pyvesync==2.1.1
# homeassistant.components.vizio
pyvizio==0.1.60
pyvizio==0.1.61
# homeassistant.components.volumio
pyvolumio==0.1.5
@@ -1730,7 +1730,7 @@ spotipy==2.23.0
# homeassistant.components.recorder
# homeassistant.components.sql
sqlalchemy==2.0.10
sqlalchemy==2.0.11
# homeassistant.components.srp_energy
srpenergy==1.3.6
@@ -1964,7 +1964,7 @@ zeroconf==0.58.2
zeversolar==0.3.1
# homeassistant.components.zha
zha-quirks==0.0.97
zha-quirks==0.0.98
# homeassistant.components.zha
zigpy-deconz==0.21.0
@@ -730,6 +730,52 @@ async def test_zeroconf_ip_change_via_secondary_identifier(
assert len(mock_async_setup.mock_calls) == 2
assert entry.data[CONF_ADDRESS] == "127.0.0.1"
assert unrelated_entry.data[CONF_ADDRESS] == "127.0.0.2"
assert set(entry.data[CONF_IDENTIFIERS]) == {"airplayid", "mrpid"}
async def test_zeroconf_updates_identifiers_for_ignored_entries(
hass: HomeAssistant, mock_scan
) -> None:
"""Test that an ignored config entry gets updated when the ip changes.
Instead of checking only the unique id, all the identifiers
in the config entry are checked
"""
entry = MockConfigEntry(
domain="apple_tv",
unique_id="aa:bb:cc:dd:ee:ff",
source=config_entries.SOURCE_IGNORE,
data={CONF_IDENTIFIERS: ["mrpid"], CONF_ADDRESS: "127.0.0.2"},
)
unrelated_entry = MockConfigEntry(
domain="apple_tv", unique_id="unrelated", data={CONF_ADDRESS: "127.0.0.2"}
)
unrelated_entry.add_to_hass(hass)
entry.add_to_hass(hass)
mock_scan.result = [
create_conf(
IPv4Address("127.0.0.1"), "Device", mrp_service(), airplay_service()
)
]
with patch(
"homeassistant.components.apple_tv.async_setup_entry", return_value=True
) as mock_async_setup:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=DMAP_SERVICE,
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert (
len(mock_async_setup.mock_calls) == 0
) # Should not be called because entry is ignored
assert entry.data[CONF_ADDRESS] == "127.0.0.1"
assert unrelated_entry.data[CONF_ADDRESS] == "127.0.0.2"
assert set(entry.data[CONF_IDENTIFIERS]) == {"airplayid", "mrpid"}
async def test_zeroconf_add_existing_aborts(hass: HomeAssistant, dmap_device) -> None:
@@ -1,5 +1,4 @@
"""Tests for the Voice Assistant integration."""
MANY_LANGUAGES = [
"ar",
"bg",
@@ -4,7 +4,7 @@
dict({
'data': dict({
'language': 'en',
'pipeline': 'Home Assistant',
'pipeline': <ANY>,
}),
'type': <PipelineEventType.RUN_START: 'run-start'>,
}),
@@ -91,7 +91,7 @@
dict({
'data': dict({
'language': 'en',
'pipeline': 'test_name',
'pipeline': <ANY>,
}),
'type': <PipelineEventType.RUN_START: 'run-start'>,
}),
@@ -178,7 +178,7 @@
dict({
'data': dict({
'language': 'en',
'pipeline': 'test_name',
'pipeline': <ANY>,
}),
'type': <PipelineEventType.RUN_START: 'run-start'>,
}),
@@ -2,7 +2,7 @@
# name: test_audio_pipeline
dict({
'language': 'en',
'pipeline': 'Home Assistant',
'pipeline': <ANY>,
'runner_data': dict({
'stt_binary_handler_id': 1,
'timeout': 30,
@@ -78,7 +78,7 @@
# name: test_audio_pipeline_debug
dict({
'language': 'en',
'pipeline': 'Home Assistant',
'pipeline': <ANY>,
'runner_data': dict({
'stt_binary_handler_id': 1,
'timeout': 30,
@@ -154,7 +154,7 @@
# name: test_intent_failed
dict({
'language': 'en',
'pipeline': 'Home Assistant',
'pipeline': <ANY>,
'runner_data': dict({
'stt_binary_handler_id': None,
'timeout': 30,
@@ -171,7 +171,7 @@
# name: test_intent_timeout
dict({
'language': 'en',
'pipeline': 'Home Assistant',
'pipeline': <ANY>,
'runner_data': dict({
'stt_binary_handler_id': None,
'timeout': 0.1,
@@ -217,7 +217,7 @@
# name: test_stt_stream_failed
dict({
'language': 'en',
'pipeline': 'Home Assistant',
'pipeline': <ANY>,
'runner_data': dict({
'stt_binary_handler_id': 1,
'timeout': 30,
@@ -240,7 +240,7 @@
# name: test_text_only_pipeline
dict({
'language': 'en',
'pipeline': 'Home Assistant',
'pipeline': <ANY>,
'runner_data': dict({
'stt_binary_handler_id': None,
'timeout': 30,
@@ -285,7 +285,7 @@
# name: test_tts_failed
dict({
'language': 'en',
'pipeline': 'Home Assistant',
'pipeline': <ANY>,
'runner_data': dict({
'stt_binary_handler_id': None,
'timeout': 30,
+17 -21
View File
@@ -1,5 +1,6 @@
"""Test Voice Assistant init."""
from dataclasses import asdict
from unittest.mock import ANY
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -12,6 +13,19 @@ from .conftest import MockSttProvider, MockSttProviderEntity
from tests.typing import WebSocketGenerator
def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]:
"""Process events to remove dynamic values."""
processed = []
for event in events:
as_dict = asdict(event)
as_dict.pop("timestamp")
if as_dict["type"] == assist_pipeline.PipelineEventType.RUN_START:
as_dict["data"]["pipeline"] = ANY
processed.append(as_dict)
return processed
async def test_pipeline_from_audio_stream_auto(
hass: HomeAssistant,
mock_stt_provider: MockSttProvider,
@@ -45,13 +59,7 @@ async def test_pipeline_from_audio_stream_auto(
audio_data(),
)
processed = []
for event in events:
as_dict = asdict(event)
as_dict.pop("timestamp")
processed.append(as_dict)
assert processed == snapshot
assert process_events(events) == snapshot
assert mock_stt_provider.received == [b"part1", b"part2"]
@@ -111,13 +119,7 @@ async def test_pipeline_from_audio_stream_legacy(
pipeline_id=pipeline_id,
)
processed = []
for event in events:
as_dict = asdict(event)
as_dict.pop("timestamp")
processed.append(as_dict)
assert processed == snapshot
assert process_events(events) == snapshot
assert mock_stt_provider.received == [b"part1", b"part2"]
@@ -177,13 +179,7 @@ async def test_pipeline_from_audio_stream_entity(
pipeline_id=pipeline_id,
)
processed = []
for event in events:
as_dict = asdict(event)
as_dict.pop("timestamp")
processed.append(as_dict)
assert processed == snapshot
assert process_events(events) == snapshot
assert mock_stt_provider_entity.received == [b"part1", b"part2"]
@@ -1,6 +1,6 @@
"""Websocket tests for Voice Assistant integration."""
import asyncio
from unittest.mock import ANY, MagicMock, patch
from unittest.mock import ANY, patch
from syrupy.assertion import SnapshotAssertion
@@ -37,6 +37,7 @@ async def test_text_only_pipeline(
# run start
msg = await client.receive_json()
assert msg["event"]["type"] == "run-start"
msg["event"]["data"]["pipeline"] = ANY
assert msg["event"]["data"] == snapshot
events.append(msg["event"])
@@ -101,6 +102,7 @@ async def test_audio_pipeline(
# run start
msg = await client.receive_json()
assert msg["event"]["type"] == "run-start"
msg["event"]["data"]["pipeline"] = ANY
assert msg["event"]["data"] == snapshot
events.append(msg["event"])
@@ -196,6 +198,7 @@ async def test_intent_timeout(
# run start
msg = await client.receive_json()
assert msg["event"]["type"] == "run-start"
msg["event"]["data"]["pipeline"] = ANY
assert msg["event"]["data"] == snapshot
events.append(msg["event"])
@@ -292,7 +295,7 @@ async def test_intent_failed(
with patch(
"homeassistant.components.conversation.async_converse",
new=MagicMock(return_value=RuntimeError),
side_effect=RuntimeError,
):
await client.send_json_auto_id(
{
@@ -310,6 +313,7 @@ async def test_intent_failed(
# run start
msg = await client.receive_json()
assert msg["event"]["type"] == "run-start"
msg["event"]["data"]["pipeline"] = ANY
assert msg["event"]["data"] == snapshot
events.append(msg["event"])
@@ -405,7 +409,7 @@ async def test_stt_provider_missing(
"""Test events from a pipeline run with a non-existent STT provider."""
with patch(
"homeassistant.components.stt.async_get_provider",
new=MagicMock(return_value=None),
return_value=None,
):
client = await hass_ws_client(hass)
@@ -438,7 +442,7 @@ async def test_stt_stream_failed(
with patch(
"tests.components.assist_pipeline.conftest.MockSttProvider.async_process_audio_stream",
new=MagicMock(side_effect=RuntimeError),
side_effect=RuntimeError,
):
await client.send_json_auto_id(
{
@@ -458,6 +462,7 @@ async def test_stt_stream_failed(
# run start
msg = await client.receive_json()
assert msg["event"]["type"] == "run-start"
msg["event"]["data"]["pipeline"] = ANY
assert msg["event"]["data"] == snapshot
events.append(msg["event"])
@@ -504,7 +509,7 @@ async def test_tts_failed(
with patch(
"homeassistant.components.media_source.async_resolve_media",
new=MagicMock(return_value=RuntimeError),
side_effect=RuntimeError,
):
await client.send_json_auto_id(
{
@@ -522,6 +527,7 @@ async def test_tts_failed(
# run start
msg = await client.receive_json()
assert msg["event"]["type"] == "run-start"
msg["event"]["data"]["pipeline"] = ANY
assert msg["event"]["data"] == snapshot
events.append(msg["event"])
@@ -1105,6 +1111,7 @@ async def test_audio_pipeline_debug(
# run start
msg = await client.receive_json()
assert msg["event"]["type"] == "run-start"
msg["event"]["data"]["pipeline"] = ANY
assert msg["event"]["data"] == snapshot
events.append(msg["event"])
+10 -4
View File
@@ -25,8 +25,14 @@ from homeassistant.components.fan import (
SERVICE_SET_DIRECTION,
SERVICE_SET_PERCENTAGE,
SERVICE_SET_PRESET_MODE,
FanEntityFeature,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -211,9 +217,9 @@ async def test_turn_on_fan_preset_mode(hass: HomeAssistant) -> None:
bond_device_id="test-device-id",
props={"max_speed": 6},
)
assert hass.states.get("fan.name_1").attributes[ATTR_PRESET_MODES] == [
PRESET_MODE_BREEZE
]
state = hass.states.get("fan.name_1")
assert state.attributes[ATTR_PRESET_MODES] == [PRESET_MODE_BREEZE]
assert state.attributes[ATTR_SUPPORTED_FEATURES] & FanEntityFeature.PRESET_MODE
with patch_bond_action() as mock_set_preset_mode, patch_bond_device_state():
await turn_fan_on(hass, "fan.name_1", preset_mode=PRESET_MODE_BREEZE)
@@ -650,3 +650,101 @@ async def test_alexa_config_migrate_expose_entity_prefs_default_none(
entity_default = entity_registry.async_get(entity_default.entity_id)
assert entity_default.options == {"cloud.alexa": {"should_expose": True}}
async def test_alexa_config_migrate_expose_entity_prefs_default(
hass: HomeAssistant,
cloud_prefs: CloudPreferences,
cloud_stub,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migrating Alexa entity config."""
assert await async_setup_component(hass, "homeassistant", {})
binary_sensor_supported = entity_registry.async_get_or_create(
"binary_sensor",
"test",
"binary_sensor_supported",
original_device_class="door",
suggested_object_id="supported",
)
binary_sensor_unsupported = entity_registry.async_get_or_create(
"binary_sensor",
"test",
"binary_sensor_unsupported",
original_device_class="battery",
suggested_object_id="unsupported",
)
light = entity_registry.async_get_or_create(
"light",
"test",
"unique",
suggested_object_id="light",
)
sensor_supported = entity_registry.async_get_or_create(
"sensor",
"test",
"sensor_supported",
original_device_class="temperature",
suggested_object_id="supported",
)
sensor_unsupported = entity_registry.async_get_or_create(
"sensor",
"test",
"sensor_unsupported",
original_device_class="battery",
suggested_object_id="unsupported",
)
water_heater = entity_registry.async_get_or_create(
"water_heater",
"test",
"unique",
suggested_object_id="water_heater",
)
await cloud_prefs.async_update(
alexa_enabled=True,
alexa_report_state=False,
alexa_settings_version=1,
)
cloud_prefs._prefs[PREF_ALEXA_DEFAULT_EXPOSE] = [
"binary_sensor",
"light",
"sensor",
"water_heater",
]
conf = alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
)
await conf.async_initialize()
binary_sensor_supported = entity_registry.async_get(
binary_sensor_supported.entity_id
)
assert binary_sensor_supported.options == {"cloud.alexa": {"should_expose": True}}
binary_sensor_unsupported = entity_registry.async_get(
binary_sensor_unsupported.entity_id
)
assert binary_sensor_unsupported.options == {
"cloud.alexa": {"should_expose": False}
}
light = entity_registry.async_get(light.entity_id)
assert light.options == {"cloud.alexa": {"should_expose": True}}
sensor_supported = entity_registry.async_get(sensor_supported.entity_id)
assert sensor_supported.options == {"cloud.alexa": {"should_expose": True}}
sensor_unsupported = entity_registry.async_get(sensor_unsupported.entity_id)
assert sensor_unsupported.options == {"cloud.alexa": {"should_expose": False}}
water_heater = entity_registry.async_get(water_heater.entity_id)
assert water_heater.options == {"cloud.alexa": {"should_expose": False}}
@@ -611,3 +611,106 @@ async def test_google_config_migrate_expose_entity_prefs_default_none(
entity_default = entity_registry.async_get(entity_default.entity_id)
assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}}
async def test_google_config_migrate_expose_entity_prefs_default(
hass: HomeAssistant,
cloud_prefs: CloudPreferences,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migrating Google entity config."""
assert await async_setup_component(hass, "homeassistant", {})
binary_sensor_supported = entity_registry.async_get_or_create(
"binary_sensor",
"test",
"binary_sensor_supported",
original_device_class="door",
suggested_object_id="supported",
)
binary_sensor_unsupported = entity_registry.async_get_or_create(
"binary_sensor",
"test",
"binary_sensor_unsupported",
original_device_class="battery",
suggested_object_id="unsupported",
)
light = entity_registry.async_get_or_create(
"light",
"test",
"unique",
suggested_object_id="light",
)
sensor_supported = entity_registry.async_get_or_create(
"sensor",
"test",
"sensor_supported",
original_device_class="temperature",
suggested_object_id="supported",
)
sensor_unsupported = entity_registry.async_get_or_create(
"sensor",
"test",
"sensor_unsupported",
original_device_class="battery",
suggested_object_id="unsupported",
)
water_heater = entity_registry.async_get_or_create(
"water_heater",
"test",
"unique",
suggested_object_id="water_heater",
)
await cloud_prefs.async_update(
google_enabled=True,
google_report_state=False,
google_settings_version=1,
)
cloud_prefs._prefs[PREF_GOOGLE_DEFAULT_EXPOSE] = [
"binary_sensor",
"light",
"sensor",
"water_heater",
]
conf = CloudGoogleConfig(
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
)
await conf.async_initialize()
binary_sensor_supported = entity_registry.async_get(
binary_sensor_supported.entity_id
)
assert binary_sensor_supported.options == {
"cloud.google_assistant": {"should_expose": True}
}
binary_sensor_unsupported = entity_registry.async_get(
binary_sensor_unsupported.entity_id
)
assert binary_sensor_unsupported.options == {
"cloud.google_assistant": {"should_expose": False}
}
light = entity_registry.async_get(light.entity_id)
assert light.options == {"cloud.google_assistant": {"should_expose": True}}
sensor_supported = entity_registry.async_get(sensor_supported.entity_id)
assert sensor_supported.options == {
"cloud.google_assistant": {"should_expose": True}
}
sensor_unsupported = entity_registry.async_get(sensor_unsupported.entity_id)
assert sensor_unsupported.options == {
"cloud.google_assistant": {"should_expose": False}
}
water_heater = entity_registry.async_get(water_heater.entity_id)
assert water_heater.options == {"cloud.google_assistant": {"should_expose": False}}
+69 -1
View File
@@ -16,6 +16,7 @@ from homeassistant.components.cloud.const import DOMAIN
from homeassistant.components.google_assistant.helpers import GoogleEntity
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util.location import LocationInfo
from . import mock_cloud, mock_cloud_prefs
@@ -106,6 +107,8 @@ async def test_google_actions_sync_fails(
async def test_login_view(hass: HomeAssistant, cloud_client) -> None:
"""Test logging in when an assist pipeline is available."""
hass.data["cloud"] = MagicMock(login=AsyncMock())
await async_setup_component(hass, "stt", {})
await async_setup_component(hass, "tts", {})
with patch(
"homeassistant.components.cloud.http_api.assist_pipeline.async_get_pipelines",
@@ -126,13 +129,15 @@ async def test_login_view(hass: HomeAssistant, cloud_client) -> None:
assert req.status == HTTPStatus.OK
result = await req.json()
assert result == {"success": True, "cloud_pipeline": "12345"}
assert result == {"success": True, "cloud_pipeline": None}
create_pipeline_mock.assert_not_awaited()
async def test_login_view_create_pipeline(hass: HomeAssistant, cloud_client) -> None:
"""Test logging in when no assist pipeline is available."""
hass.data["cloud"] = MagicMock(login=AsyncMock())
await async_setup_component(hass, "stt", {})
await async_setup_component(hass, "tts", {})
with patch(
"homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline",
@@ -153,6 +158,8 @@ async def test_login_view_create_pipeline_fail(
) -> None:
"""Test logging in when no assist pipeline is available."""
hass.data["cloud"] = MagicMock(login=AsyncMock())
await async_setup_component(hass, "stt", {})
await async_setup_component(hass, "tts", {})
with patch(
"homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline",
@@ -931,6 +938,67 @@ async def test_list_alexa_entities(
}
async def test_get_alexa_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
setup_api,
mock_cloud_login,
) -> None:
"""Test that we can get an Alexa entity."""
client = await hass_ws_client(hass)
# Test getting an unknown entity
await client.send_json_auto_id(
{"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"}
)
response = await client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "not_found",
"message": "light.kitchen not in the entity registry",
}
# Test getting a blocked entity
entity_registry.async_get_or_create(
"group", "test", "unique", suggested_object_id="all_locks"
)
hass.states.async_set("group.all_locks", "bla")
await client.send_json_auto_id(
{"type": "cloud/alexa/entities/get", "entity_id": "group.all_locks"}
)
response = await client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "not_supported",
"message": "group.all_locks not supported by Alexa",
}
entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
entity_registry.async_get_or_create(
"water_heater", "test", "unique", suggested_object_id="basement"
)
await client.send_json_auto_id(
{"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] is None
await client.send_json_auto_id(
{"type": "cloud/alexa/entities/get", "entity_id": "water_heater.basement"}
)
response = await client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "not_supported",
"message": "water_heater.basement not supported by Alexa",
}
async def test_update_alexa_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
@@ -4,6 +4,9 @@ from unittest.mock import patch
import pytest
from homeassistant.components import conversation
from homeassistant.components.homeassistant.exposed_entities import (
async_get_assistant_settings,
)
from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant
from homeassistant.helpers import (
@@ -137,3 +140,34 @@ async def test_conversation_agent(
return_value={"homeassistant": ["dwarvish", "elvish", "entish"]},
):
assert agent.supported_languages == ["dwarvish", "elvish", "entish"]
async def test_expose_flag_automatically_set(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test DefaultAgent sets the expose flag on all entities automatically."""
assert await async_setup_component(hass, "homeassistant", {})
light = entity_registry.async_get_or_create("light", "demo", "1234")
test = entity_registry.async_get_or_create("test", "demo", "1234")
assert async_get_assistant_settings(hass, conversation.DOMAIN) == {}
assert await async_setup_component(hass, "conversation", {})
await hass.async_block_till_done()
# After setting up conversation, the expose flag should now be set on all entities
assert async_get_assistant_settings(hass, conversation.DOMAIN) == {
light.entity_id: {"should_expose": True},
test.entity_id: {"should_expose": False},
}
# New entities will automatically have the expose flag set
new_light = entity_registry.async_get_or_create("light", "demo", "2345")
await hass.async_block_till_done()
assert async_get_assistant_settings(hass, conversation.DOMAIN) == {
light.entity_id: {"should_expose": True},
new_light.entity_id: {"should_expose": True},
test.entity_id: {"should_expose": False},
}
@@ -17,6 +17,8 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import IntegrationNotFound
from homeassistant.requirements import RequirementsNotFound
from homeassistant.setup import async_setup_component
from tests.common import (
@@ -1554,3 +1556,25 @@ async def test_automation_with_device_component_not_loaded(
)
module.async_validate_trigger_config.assert_not_awaited()
@pytest.mark.parametrize(
"exc",
[
IntegrationNotFound("test"),
RequirementsNotFound("test", []),
ImportError("test"),
],
)
async def test_async_get_device_automations_platform_reraises_exceptions(
hass: HomeAssistant, exc: Exception
) -> None:
"""Test InvalidDeviceAutomationConfig is raised when async_get_integration_with_requirements fails."""
await async_setup_component(hass, "device_automation", {})
with patch(
"homeassistant.components.device_automation.async_get_integration_with_requirements",
side_effect=exc,
), pytest.raises(InvalidDeviceAutomationConfig):
await device_automation.async_get_device_automation_platform(
hass, "test", device_automation.DeviceAutomationType.TRIGGER
)
+35
View File
@@ -157,3 +157,38 @@ async def mock_voice_assistant_v1_entry(
await hass.async_block_till_done()
return entry
@pytest.fixture
async def mock_voice_assistant_v2_entry(
hass: HomeAssistant,
mock_client,
) -> MockConfigEntry:
"""Set up an ESPHome entry with voice assistant."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "test.local",
CONF_PORT: 6053,
CONF_PASSWORD: "",
},
)
entry.add_to_hass(hass)
device_info = DeviceInfo(
name="test",
friendly_name="Test",
voice_assistant_version=2,
mac_address="11:22:33:44:55:aa",
esphome_version="1.0.0",
)
mock_client.device_info = AsyncMock(return_value=device_info)
mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock())
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
await hass.async_block_till_done()
return entry
@@ -5,24 +5,24 @@ from homeassistant.components.esphome import DomainData
from homeassistant.core import HomeAssistant
async def test_call_active(
async def test_assist_in_progress(
hass: HomeAssistant,
mock_voice_assistant_v1_entry,
) -> None:
"""Test call active binary sensor."""
"""Test assist in progress binary sensor."""
entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v1_entry)
state = hass.states.get("binary_sensor.test_call_active")
state = hass.states.get("binary_sensor.test_assist_in_progress")
assert state is not None
assert state.state == "off"
entry_data.async_set_assist_pipeline_state(True)
state = hass.states.get("binary_sensor.test_call_active")
state = hass.states.get("binary_sensor.test_assist_in_progress")
assert state.state == "on"
entry_data.async_set_assist_pipeline_state(False)
state = hass.states.get("binary_sensor.test_call_active")
state = hass.states.get("binary_sensor.test_assist_in_progress")
assert state.state == "off"
+41
View File
@@ -1,4 +1,5 @@
"""Test ESPHome update entities."""
import asyncio
import dataclasses
from unittest.mock import Mock, patch
@@ -197,3 +198,43 @@ async def test_update_device_state_for_availability(
state = hass.states.get("update.none_firmware")
assert state.state == "on"
async def test_update_entity_dashboard_not_available_startup(
hass: HomeAssistant, mock_config_entry, mock_device_info, mock_dashboard
) -> None:
"""Test ESPHome update entity when dashboard is not available at startup."""
with patch(
"homeassistant.components.esphome.update.DomainData.get_entry_data",
return_value=Mock(available=True, device_info=mock_device_info),
), patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.get_devices",
side_effect=asyncio.TimeoutError,
):
await async_get_dashboard(hass).async_refresh()
assert await hass.config_entries.async_forward_entry_setup(
mock_config_entry, "update"
)
state = hass.states.get("update.none_firmware")
assert state is None
mock_dashboard["configured"] = [
{
"name": "test",
"current_version": "2023.2.0-dev",
"configuration": "test.yaml",
}
]
await async_get_dashboard(hass).async_refresh()
await hass.async_block_till_done()
state = hass.states.get("update.none_firmware")
assert state.state == "on"
expected_attributes = {
"latest_version": "2023.2.0-dev",
"installed_version": "1.0.0",
"supported_features": UpdateEntityFeature.INSTALL,
}
for key, expected_value in expected_attributes.items():
assert state.attributes.get(key) == expected_value
+222 -26
View File
@@ -4,18 +4,64 @@ import asyncio
import socket
from unittest.mock import Mock, patch
from aioesphomeapi import VoiceAssistantEventType
import async_timeout
import pytest
from homeassistant.components import assist_pipeline, esphome
from homeassistant.components import esphome
from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType
from homeassistant.components.esphome import DomainData
from homeassistant.components.esphome.voice_assistant import VoiceAssistantUDPServer
from homeassistant.core import HomeAssistant
_TEST_INPUT_TEXT = "This is an input test"
_TEST_OUTPUT_TEXT = "This is an output test"
_TEST_OUTPUT_URL = "output.mp3"
_TEST_MEDIA_ID = "12345"
async def test_pipeline_events(hass: HomeAssistant) -> None:
@pytest.fixture
def voice_assistant_udp_server_v1(
hass: HomeAssistant,
mock_voice_assistant_v1_entry,
) -> VoiceAssistantUDPServer:
"""Return the UDP server."""
entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v1_entry)
server: VoiceAssistantUDPServer = None
def handle_finished():
nonlocal server
assert server is not None
server.close()
server = VoiceAssistantUDPServer(hass, entry_data, Mock(), handle_finished)
return server
@pytest.fixture
def voice_assistant_udp_server_v2(
hass: HomeAssistant,
mock_voice_assistant_v2_entry,
) -> VoiceAssistantUDPServer:
"""Return the UDP server."""
entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v2_entry)
server: VoiceAssistantUDPServer = None
def handle_finished():
nonlocal server
assert server is not None
server.close()
server = VoiceAssistantUDPServer(hass, entry_data, Mock(), handle_finished)
return server
async def test_pipeline_events(
hass: HomeAssistant,
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
) -> None:
"""Test that the pipeline function is called."""
async def async_pipeline_from_audio_stream(*args, **kwargs):
@@ -23,29 +69,29 @@ async def test_pipeline_events(hass: HomeAssistant) -> None:
# Fake events
event_callback(
assist_pipeline.PipelineEvent(
type=assist_pipeline.PipelineEventType.STT_START,
PipelineEvent(
type=PipelineEventType.STT_START,
data={},
)
)
event_callback(
assist_pipeline.PipelineEvent(
type=assist_pipeline.PipelineEventType.STT_END,
PipelineEvent(
type=PipelineEventType.STT_END,
data={"stt_output": {"text": _TEST_INPUT_TEXT}},
)
)
event_callback(
assist_pipeline.PipelineEvent(
type=assist_pipeline.PipelineEventType.TTS_START,
PipelineEvent(
type=PipelineEventType.TTS_START,
data={"tts_input": _TEST_OUTPUT_TEXT},
)
)
event_callback(
assist_pipeline.PipelineEvent(
type=assist_pipeline.PipelineEventType.TTS_END,
PipelineEvent(
type=PipelineEventType.TTS_END,
data={"tts_output": {"url": _TEST_OUTPUT_URL}},
)
)
@@ -63,79 +109,229 @@ async def test_pipeline_events(hass: HomeAssistant) -> None:
assert data is not None
assert data["url"] == _TEST_OUTPUT_URL
voice_assistant_udp_server_v1.handle_event = handle_event
with patch(
"homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream",
new=async_pipeline_from_audio_stream,
):
server = esphome.voice_assistant.VoiceAssistantUDPServer(hass)
server.transport = Mock()
voice_assistant_udp_server_v1.transport = Mock()
await server.run_pipeline(handle_event)
await voice_assistant_udp_server_v1.run_pipeline()
async def test_udp_server(
hass: HomeAssistant,
socket_enabled,
unused_udp_port_factory,
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
) -> None:
"""Test the UDP server runs and queues incoming data."""
port_to_use = unused_udp_port_factory()
server = esphome.voice_assistant.VoiceAssistantUDPServer(hass)
with patch(
"homeassistant.components.esphome.voice_assistant.UDP_PORT", new=port_to_use
):
port = await server.start_server()
port = await voice_assistant_udp_server_v1.start_server()
assert port == port_to_use
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
assert server.queue.qsize() == 0
assert voice_assistant_udp_server_v1.queue.qsize() == 0
sock.sendto(b"test", ("127.0.0.1", port))
# Give the socket some time to send/receive the data
async with async_timeout.timeout(1):
while server.queue.qsize() == 0:
while voice_assistant_udp_server_v1.queue.qsize() == 0:
await asyncio.sleep(0.1)
assert server.queue.qsize() == 1
assert voice_assistant_udp_server_v1.queue.qsize() == 1
server.stop()
voice_assistant_udp_server_v1.stop()
voice_assistant_udp_server_v1.close()
assert server.transport.is_closing()
assert voice_assistant_udp_server_v1.transport.is_closing()
async def test_udp_server_queue(
hass: HomeAssistant,
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
) -> None:
"""Test the UDP server queues incoming data."""
voice_assistant_udp_server_v1.started = True
assert voice_assistant_udp_server_v1.queue.qsize() == 0
voice_assistant_udp_server_v1.datagram_received(bytes(1024), ("localhost", 0))
assert voice_assistant_udp_server_v1.queue.qsize() == 1
voice_assistant_udp_server_v1.datagram_received(bytes(1024), ("localhost", 0))
assert voice_assistant_udp_server_v1.queue.qsize() == 2
async for data in voice_assistant_udp_server_v1._iterate_packets():
assert data == bytes(1024)
break
assert voice_assistant_udp_server_v1.queue.qsize() == 1 # One message removed
voice_assistant_udp_server_v1.stop()
assert (
voice_assistant_udp_server_v1.queue.qsize() == 2
) # An empty message added by stop
voice_assistant_udp_server_v1.datagram_received(bytes(1024), ("localhost", 0))
assert (
voice_assistant_udp_server_v1.queue.qsize() == 2
) # No new messages added after stop
voice_assistant_udp_server_v1.close()
with pytest.raises(RuntimeError):
async for data in voice_assistant_udp_server_v1._iterate_packets():
assert data == bytes(1024)
async def test_error_calls_handle_finished(
hass: HomeAssistant,
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
) -> None:
"""Test that the handle_finished callback is called when an error occurs."""
voice_assistant_udp_server_v1.handle_finished = Mock()
voice_assistant_udp_server_v1.error_received(Exception())
voice_assistant_udp_server_v1.handle_finished.assert_called()
async def test_udp_server_multiple(
hass: HomeAssistant,
socket_enabled,
unused_udp_port_factory,
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
) -> None:
"""Test that the UDP server raises an error if started twice."""
server = esphome.voice_assistant.VoiceAssistantUDPServer(hass)
with patch(
"homeassistant.components.esphome.voice_assistant.UDP_PORT",
new=unused_udp_port_factory(),
):
await server.start_server()
await voice_assistant_udp_server_v1.start_server()
with patch(
"homeassistant.components.esphome.voice_assistant.UDP_PORT",
new=unused_udp_port_factory(),
), pytest.raises(RuntimeError):
pass
await server.start_server()
await voice_assistant_udp_server_v1.start_server()
async def test_udp_server_after_stopped(
hass: HomeAssistant,
socket_enabled,
unused_udp_port_factory,
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
) -> None:
"""Test that the UDP server raises an error if started after stopped."""
server = esphome.voice_assistant.VoiceAssistantUDPServer(hass)
server.stop()
voice_assistant_udp_server_v1.close()
with patch(
"homeassistant.components.esphome.voice_assistant.UDP_PORT",
new=unused_udp_port_factory(),
), pytest.raises(RuntimeError):
await server.start_server()
await voice_assistant_udp_server_v1.start_server()
async def test_unknown_event_type(
hass: HomeAssistant,
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
) -> None:
"""Test the UDP server does not call handle_event for unknown events."""
voice_assistant_udp_server_v1._event_callback(
PipelineEvent(
type="unknown-event",
data={},
)
)
assert not voice_assistant_udp_server_v1.handle_event.called
async def test_error_event_type(
hass: HomeAssistant,
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
) -> None:
"""Test the UDP server calls event handler with error."""
voice_assistant_udp_server_v1._event_callback(
PipelineEvent(
type=PipelineEventType.ERROR,
data={"code": "code", "message": "message"},
)
)
assert voice_assistant_udp_server_v1.handle_event.called_with(
VoiceAssistantEventType.VOICE_ASSISTANT_ERROR,
{"code": "code", "message": "message"},
)
async def test_send_tts_not_called(
hass: HomeAssistant,
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
) -> None:
"""Test the UDP server with a v1 device does not call _send_tts."""
with patch(
"homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts"
) as mock_send_tts:
voice_assistant_udp_server_v1._event_callback(
PipelineEvent(
type=PipelineEventType.TTS_END,
data={
"tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL}
},
)
)
mock_send_tts.assert_not_called()
async def test_send_tts_called(
hass: HomeAssistant,
voice_assistant_udp_server_v2: VoiceAssistantUDPServer,
) -> None:
"""Test the UDP server with a v2 device calls _send_tts."""
with patch(
"homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts"
) as mock_send_tts:
voice_assistant_udp_server_v2._event_callback(
PipelineEvent(
type=PipelineEventType.TTS_END,
data={
"tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL}
},
)
)
mock_send_tts.assert_called_with(_TEST_MEDIA_ID)
async def test_send_tts(
hass: HomeAssistant,
voice_assistant_udp_server_v2: VoiceAssistantUDPServer,
) -> None:
"""Test the UDP server calls sendto to transmit audio data to device."""
with patch(
"homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio",
return_value=("raw", bytes(1024)),
):
voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport)
voice_assistant_udp_server_v2._event_callback(
PipelineEvent(
type=PipelineEventType.TTS_END,
data={
"tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL}
},
)
)
await voice_assistant_udp_server_v2._tts_done.wait()
voice_assistant_udp_server_v2.transport.sendto.assert_called()
@@ -72,6 +72,7 @@ def mock_forecast_solar(hass) -> Generator[None, MagicMock, None]:
estimate.api_rate_limit = 60
estimate.account_type.value = "public"
estimate.energy_production_today = 100000
estimate.energy_production_today_remaining = 50000
estimate.energy_production_tomorrow = 200000
estimate.power_production_now = 300000
estimate.power_highest_peak_time_today = datetime(
@@ -34,6 +34,7 @@ async def test_diagnostics(
},
"data": {
"energy_production_today": 100000,
"energy_production_today_remaining": 50000,
"energy_production_tomorrow": 200000,
"energy_current_hour": 800000,
"power_production_now": 300000,
@@ -48,6 +48,21 @@ async def test_sensors(
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY
assert ATTR_ICON not in state.attributes
state = hass.states.get("sensor.energy_production_today_remaining")
entry = entity_registry.async_get("sensor.energy_production_today_remaining")
assert entry
assert state
assert entry.unique_id == f"{entry_id}_energy_production_today_remaining"
assert state.state == "50.0"
assert (
state.attributes.get(ATTR_FRIENDLY_NAME)
== "Solar production forecast Estimated energy production - remaining today"
)
assert state.attributes.get(ATTR_STATE_CLASS) is None
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY
assert ATTR_ICON not in state.attributes
state = hass.states.get("sensor.energy_production_tomorrow")
entry = entity_registry.async_get("sensor.energy_production_tomorrow")
assert entry
+53
View File
@@ -7,7 +7,9 @@ import aiohttp
from aiohttp import hdrs, web
import pytest
from homeassistant.components.hassio import handler
from homeassistant.components.hassio.handler import HassIO, HassioAPIError
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from tests.test_util.aiohttp import AiohttpClientMocker
@@ -360,3 +362,54 @@ async def test_api_headers(
assert received_request.headers[hdrs.CONTENT_TYPE] == "application/json"
else:
assert received_request.headers[hdrs.CONTENT_TYPE] == "application/octet-stream"
async def test_api_get_yellow_settings(
hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test setup with API ping."""
aioclient_mock.get(
"http://127.0.0.1/os/boards/yellow",
json={
"result": "ok",
"data": {"disk_led": True, "heartbeat_led": True, "power_led": True},
},
)
assert await handler.async_get_yellow_settings(hass) == {
"disk_led": True,
"heartbeat_led": True,
"power_led": True,
}
assert aioclient_mock.call_count == 1
async def test_api_set_yellow_settings(
hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test setup with API ping."""
aioclient_mock.post(
"http://127.0.0.1/os/boards/yellow",
json={"result": "ok", "data": {}},
)
assert (
await handler.async_set_yellow_settings(
hass, {"disk_led": True, "heartbeat_led": True, "power_led": True}
)
== {}
)
assert aioclient_mock.call_count == 1
async def test_api_reboot_host(
hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test setup with API ping."""
aioclient_mock.post(
"http://127.0.0.1/host/reboot",
json={"result": "ok", "data": {}},
)
assert await handler.async_reboot_host(hass) == {}
assert aioclient_mock.call_count == 1
@@ -1,6 +1,8 @@
"""Test the Home Assistant Yellow config flow."""
from unittest.mock import Mock, patch
import pytest
from homeassistant.components.homeassistant_yellow.const import DOMAIN
from homeassistant.components.zha.core.const import DOMAIN as ZHA_DOMAIN
from homeassistant.core import HomeAssistant
@@ -9,6 +11,34 @@ from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry, MockModule, mock_integration
@pytest.fixture(name="get_yellow_settings")
def mock_get_yellow_settings():
"""Mock getting yellow settings."""
with patch(
"homeassistant.components.homeassistant_yellow.config_flow.async_get_yellow_settings",
return_value={"disk_led": True, "heartbeat_led": True, "power_led": True},
) as get_yellow_settings:
yield get_yellow_settings
@pytest.fixture(name="set_yellow_settings")
def mock_set_yellow_settings():
"""Mock setting yellow settings."""
with patch(
"homeassistant.components.homeassistant_yellow.config_flow.async_set_yellow_settings",
) as set_yellow_settings:
yield set_yellow_settings
@pytest.fixture(name="reboot_host")
def mock_reboot_host():
"""Mock rebooting host."""
with patch(
"homeassistant.components.homeassistant_yellow.config_flow.async_reboot_host",
) as reboot_host:
yield reboot_host
async def test_config_flow(hass: HomeAssistant) -> None:
"""Test the config flow."""
mock_integration(hass, MockModule("hassio"))
@@ -79,11 +109,17 @@ async def test_option_flow_install_multi_pan_addon(
)
config_entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == FlowResultType.MENU
with patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio",
side_effect=Mock(return_value=True),
):
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "multipan_settings"},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "addon_not_installed"
@@ -155,11 +191,17 @@ async def test_option_flow_install_multi_pan_addon_zha(
)
zha_config_entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == FlowResultType.MENU
with patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio",
side_effect=Mock(return_value=True),
):
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "multipan_settings"},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "addon_not_installed"
@@ -210,3 +252,156 @@ async def test_option_flow_install_multi_pan_addon_zha(
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize(
("reboot_menu_choice", "reboot_calls"),
[("reboot_now", 1), ("reboot_later", 0)],
)
async def test_option_flow_led_settings(
hass: HomeAssistant,
get_yellow_settings,
set_yellow_settings,
reboot_host,
reboot_menu_choice,
reboot_calls,
) -> None:
"""Test updating LED settings."""
mock_integration(hass, MockModule("hassio"))
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={},
title="Home Assistant Yellow",
)
config_entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "main_menu"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "hardware_settings"},
)
assert result["type"] == FlowResultType.FORM
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"disk_led": False, "heartbeat_led": False, "power_led": False},
)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "reboot_menu"
set_yellow_settings.assert_called_once_with(
hass, {"disk_led": False, "heartbeat_led": False, "power_led": False}
)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": reboot_menu_choice},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert len(reboot_host.mock_calls) == reboot_calls
async def test_option_flow_led_settings_unchanged(
hass: HomeAssistant,
get_yellow_settings,
set_yellow_settings,
) -> None:
"""Test updating LED settings."""
mock_integration(hass, MockModule("hassio"))
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={},
title="Home Assistant Yellow",
)
config_entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "main_menu"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "hardware_settings"},
)
assert result["type"] == FlowResultType.FORM
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"disk_led": True, "heartbeat_led": True, "power_led": True},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
set_yellow_settings.assert_not_called()
async def test_option_flow_led_settings_fail_1(hass: HomeAssistant) -> None:
"""Test updating LED settings."""
mock_integration(hass, MockModule("hassio"))
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={},
title="Home Assistant Yellow",
)
config_entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "main_menu"
with patch(
"homeassistant.components.homeassistant_yellow.config_flow.async_get_yellow_settings",
side_effect=TimeoutError,
):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "hardware_settings"},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "read_hw_settings_error"
async def test_option_flow_led_settings_fail_2(
hass: HomeAssistant, get_yellow_settings
) -> None:
"""Test updating LED settings."""
mock_integration(hass, MockModule("hassio"))
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={},
title="Home Assistant Yellow",
)
config_entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "main_menu"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "hardware_settings"},
)
assert result["type"] == FlowResultType.FORM
with patch(
"homeassistant.components.homeassistant_yellow.config_flow.async_set_yellow_settings",
side_effect=TimeoutError,
):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"disk_led": False, "heartbeat_led": False, "power_led": False},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "write_hw_settings_error"
+133 -1
View File
@@ -18,9 +18,10 @@ from homeassistant.components.lutron_caseta.const import (
from homeassistant.components.lutron_caseta.models import LutronCasetaData
from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
from . import MockBridge
from . import MockBridge, async_setup_integration
from tests.common import MockConfigEntry
from tests.components.logbook.common import MockRow, mock_humanify
@@ -78,3 +79,134 @@ async def test_humanify_lutron_caseta_button_event(hass: HomeAssistant) -> None:
assert event1["name"] == "Dining Room Pico"
assert event1["domain"] == DOMAIN
assert event1["message"] == "press stop"
async def test_humanify_lutron_caseta_button_event_integration_not_loaded(
hass: HomeAssistant,
) -> None:
"""Test humanifying lutron_caseta_button_events when the integration fails to load."""
hass.config.components.add("recorder")
assert await async_setup_component(hass, "logbook", {})
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "1.1.1.1",
CONF_KEYFILE: "",
CONF_CERTFILE: "",
CONF_CA_CERTS: "",
},
unique_id="abc",
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.lutron_caseta.Smartbridge.create_tls",
return_value=MockBridge(can_connect=True),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
device_registry = dr.async_get(hass)
for device in device_registry.devices.values():
if device.config_entries == {config_entry.entry_id}:
dr_device_id = device.id
break
assert dr_device_id is not None
(event1,) = mock_humanify(
hass,
[
MockRow(
LUTRON_CASETA_BUTTON_EVENT,
{
ATTR_SERIAL: "68551522",
ATTR_DEVICE_ID: dr_device_id,
ATTR_TYPE: "Pico3ButtonRaiseLower",
ATTR_LEAP_BUTTON_NUMBER: 1,
ATTR_BUTTON_NUMBER: 1,
ATTR_DEVICE_NAME: "Pico",
ATTR_AREA_NAME: "Dining Room",
ATTR_ACTION: "press",
},
),
],
)
assert event1["name"] == "Dining Room Pico"
assert event1["domain"] == DOMAIN
assert event1["message"] == "press stop"
async def test_humanify_lutron_caseta_button_event_ra3(hass: HomeAssistant) -> None:
"""Test humanifying lutron_caseta_button_events from an RA3 hub."""
hass.config.components.add("recorder")
assert await async_setup_component(hass, "logbook", {})
await async_setup_integration(hass, MockBridge)
registry = dr.async_get(hass)
keypad = registry.async_get_device(
identifiers={(DOMAIN, 66286451)}, connections=set()
)
assert keypad
(event1,) = mock_humanify(
hass,
[
MockRow(
LUTRON_CASETA_BUTTON_EVENT,
{
ATTR_SERIAL: "66286451",
ATTR_DEVICE_ID: keypad.id,
ATTR_TYPE: keypad.model,
ATTR_LEAP_BUTTON_NUMBER: 3,
ATTR_BUTTON_NUMBER: 3,
ATTR_DEVICE_NAME: "Keypad",
ATTR_AREA_NAME: "Breakfast",
ATTR_ACTION: "press",
},
),
],
)
assert event1["name"] == "Breakfast Keypad"
assert event1["domain"] == DOMAIN
assert event1["message"] == "press Kitchen Pendants"
async def test_humanify_lutron_caseta_button_unknown_type(hass: HomeAssistant) -> None:
"""Test humanifying lutron_caseta_button_events with an unknown type."""
hass.config.components.add("recorder")
assert await async_setup_component(hass, "logbook", {})
await async_setup_integration(hass, MockBridge)
registry = dr.async_get(hass)
keypad = registry.async_get_device(
identifiers={(DOMAIN, 66286451)}, connections=set()
)
assert keypad
(event1,) = mock_humanify(
hass,
[
MockRow(
LUTRON_CASETA_BUTTON_EVENT,
{
ATTR_SERIAL: "66286451",
ATTR_DEVICE_ID: "removed",
ATTR_TYPE: keypad.model,
ATTR_LEAP_BUTTON_NUMBER: 3,
ATTR_BUTTON_NUMBER: 3,
ATTR_DEVICE_NAME: "Keypad",
ATTR_AREA_NAME: "Breakfast",
ATTR_ACTION: "press",
},
),
],
)
assert event1["name"] == "Breakfast Keypad"
assert event1["domain"] == DOMAIN
assert event1["message"] == "press Error retrieving button description"
+4 -4
View File
@@ -103,7 +103,7 @@ async def test_cover(
assert matter_client.send_device_command.call_args == call(
node_id=window_covering.node_id,
endpoint_id=1,
command=clusters.WindowCovering.Commands.GoToLiftValue(50),
command=clusters.WindowCovering.Commands.GoToLiftPercentage(5000),
)
matter_client.send_device_command.reset_mock()
@@ -121,7 +121,7 @@ async def test_cover(
state = hass.states.get("cover.longan_link_wncv_da01")
assert state
assert state.state == STATE_CLOSED
assert state.state == STATE_OPEN
set_node_attribute(window_covering, 1, 258, 8, 50)
set_node_attribute(window_covering, 1, 258, 10, 1)
@@ -137,5 +137,5 @@ async def test_cover(
state = hass.states.get("cover.longan_link_wncv_da01")
assert state
assert state.attributes["current_position"] == 100
assert state.state == STATE_OPEN
assert state.attributes["current_position"] == 0
assert state.state == STATE_CLOSED

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