Compare commits

...

100 Commits

Author SHA1 Message Date
Franck Nijhof
e8b2a3de8b 2025.4.0 (#141505) 2025-04-02 18:47:40 +02:00
Joost Lekkerkerker
39549d5dd4 Fix switch name Unknown in SmartThings (#142081)
Fix switch name Unknown
2025-04-02 15:16:50 +00:00
Franck Nijhof
0c19e47bd4 Bump version to 2025.4.0 2025-04-02 15:02:28 +00:00
Michael
05507d77e3 Fix state class for battery sensors in AVM Fritz!SmartHome (#142078)
* set proper state class for battery sensor

* fix tests
2025-04-02 15:02:04 +00:00
Franck Nijhof
94558e2d40 Bump version to 2025.4.0b15 2025-04-02 14:19:49 +00:00
puddly
4f22fe8f7f Translation key for ZBT-1 integration failing due to disconnection (#142077)
Translation key for device disconnected
2025-04-02 14:19:41 +00:00
Marcel van der Veldt
9e7dfbb857 Deprecate None effect instead of breaking it for Hue (#142073)
* Deprecate effect none instead of breaking it for Hue

* add guard for unknown effect value

* revert guard

* Fix

* Add test

* Add test

* Add test

---------

Co-authored-by: Joostlek <joostlek@outlook.com>
2025-04-02 14:19:38 +00:00
Joost Lekkerkerker
02d182239a Improve SmartThings switch deprecation (#142072) 2025-04-02 14:19:35 +00:00
Joost Lekkerkerker
4e0f581747 Improve SmartThings sensor deprecation (#142070)
* Improve SmartThings sensor deprecation

* Improve SmartThings sensor deprecation

* Improve SmartThings sensor deprecation
2025-04-02 14:19:32 +00:00
Joost Lekkerkerker
42d97d348c Add Eve brand (#142067) 2025-04-02 14:19:29 +00:00
Robert Resch
69380c85ca Bump deebot-client to 12.5.0 (#142046) 2025-04-02 14:19:25 +00:00
Abílio Costa
b38c647830 Allow excluding modules from noisy logs check (#142020)
* Allow excluding modules from noisy logs check

* Cache non-excluded modules; hardcode self module name; optimize call

* Address review comments
2025-04-02 14:19:22 +00:00
Petro31
2396fd1090 Fix weather templates using new style configuration (#136677) 2025-04-02 14:19:19 +00:00
Franck Nijhof
aa4eb89eee Bump version to 2025.4.0b14 2025-04-02 09:44:23 +00:00
J. Nick Koston
1b1bc6af95 Bump bluetooth-data-tools to 1.26.5 (#142045)
changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.26.1...v1.26.5
2025-04-02 09:36:51 +00:00
J. Nick Koston
f17003a79c Bump aiohttp to 3.11.16 (#142034)
changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.15...v3.11.16
2025-04-02 09:34:14 +00:00
TheJulianJES
ec70e8b0cd Bump ZHA to 0.0.55 (#142031) 2025-04-02 08:29:26 +00:00
puddly
d888c70ff0 Fix entity names for HA hardware firmware update entities (#142029)
* Fix entity names for HA hardware firmware update entities

* Fix unit tests
2025-04-02 08:29:23 +00:00
puddly
f29444002e Skip firmware config flow confirmation if the hardware is in use (#142017)
* Auto-confirm the discovery if we detect that the device is already in use

* Add a unit test
2025-04-02 08:29:20 +00:00
Tomek Wasilczyk
fc66997a36 Fix warning about unfinished oauth tasks on shutdown (#141969)
* Don't wait for OAuth token task on shutdown

To reproduce the warning:
1. Start authentication with integration using OAuth (e.g. SmartThings)
2. When redirected to external login site, just close the page
3. Settings -> Restart Home Assistant

* Clarify comment

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-04-02 08:29:16 +00:00
Erik Montnemery
35513ae072 Remove unused mypy ignore from google_generative_ai_conversation (#141549) 2025-04-02 08:29:13 +00:00
Franck Nijhof
cd363d48c3 Bump version to 2025.4.0b13 2025-04-01 19:12:16 +00:00
G Johansson
d47ef835d7 Fix train to for multiple stations in Trafikverket Train (#142016) 2025-04-01 19:11:51 +00:00
Bram Kragten
00177c699e Update frontend to 20250401.0 (#142010) 2025-04-01 19:11:48 +00:00
Joost Lekkerkerker
11b0086a01 Add LG ThinQ event bus listener to lifecycle hooks (#142006) 2025-04-01 19:11:44 +00:00
J. Nick Koston
ceb177f80e Bump aiohttp to 3.11.15 (#141967)
changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.14...v3.11.15

fixes #141855
fixes #141146
2025-04-01 19:10:28 +00:00
Jan Bouwhuis
fa3832fbd7 Improve error handling and logging on MQTT update entity state updates when template rederings fails (#141960) 2025-04-01 19:07:10 +00:00
puddly
2b9c903429 Fix data in old SkyConnect integration config entries or delete them (#141959)
* Delete old SkyConnect integration config entries

* Try migrating, if possible

* Do not delete config entries, log a failure
2025-04-01 19:07:07 +00:00
puddly
a7c43f9b49 Reload the ZBT-1 integration on USB state changes (#141287)
* Reload the config entry when the ZBT-1 is unplugged

* Register the USB event handler globally to react better to re-plugs

* Fix existing unit tests

* Add an empty `CONFIG_SCHEMA`

* Add a unit test

* Fix unit tests

* Fix unit tests for Linux

* Address most review comments

* Address remaining review comments
2025-04-01 19:07:03 +00:00
Joost Lekkerkerker
b428196149 Improve SmartThings deprecation (#141939)
* Improve SmartThings deprecation

* Improve SmartThings deprecation
2025-04-01 19:01:43 +00:00
Erik Montnemery
e23da1a90f Fix import issues related to onboarding views (#141919)
* Fix import issues related to onboarding views

* Add ha-intents and numpy to pyproject.toml

* Add more requirements to pyproject.toml

* Add more requirements to pyproject.toml
2025-04-01 19:00:24 +00:00
Ben Jones
3951c2ea66 Handle empty or missing state values for MQTT light entities using 'template' schema (#141177)
* check for empty or missing values when processing state messages for MQTT light entities using 'template' schema

* normalise warning logs

* add tests (one is still failing and I can't work out why)

* fix test

* improve test coverage after PR review

* improve test coverage after PR review
2025-04-01 18:32:50 +00:00
Louis Christ
fee152654d Use saved volume when selecting preset in bluesound integration (#141079)
* Use load_preset to select preset as source

* Add tests

* Fix

---------

Co-authored-by: Joostlek <joostlek@outlook.com>
2025-04-01 18:32:47 +00:00
Mikko Koo
51073c948c Fix nordpool Not to return Unknown if price is exactly 0 (#140647)
* now the price will return even if it is exactly 0

* now the price will return even if it is exactly 0

* now the price will return even if it is exactly 0

* clean code

* clean code

* update testing code coverage

* change zero testing to SE4

* remove row duplicate

* fix date comments

* improve testing

* simplify if-return-0

* remove unnecessary tests

* order testing rows

* restore test_sensor_no_next_price

* remove_average_price_test

* fix test name
2025-04-01 18:32:44 +00:00
aaronburt
91438088a0 Correct unit conversion for OneDrive quota display (#140337)
* Correct unit conversion for OneDrive quota display

* Convert OneDrive quota values from bytes to GiB in coordinator and update strings
2025-04-01 18:32:39 +00:00
Franck Nijhof
427e1abdae Bump version to 2025.4.0b12 2025-03-31 20:12:58 +00:00
Steven Looman
6e7ac45ac0 Bump async-upnp-client to 0.44.0 (#141946) 2025-03-31 20:12:48 +00:00
Bram Kragten
4b3b9ebc29 Update frontend to 20250331.0 (#141943) 2025-03-31 20:12:43 +00:00
Franck Nijhof
649d8638ed Bump version to 2025.4.0b11 2025-03-31 18:34:34 +00:00
Jan-Philipp Benecke
12c4152dbe Bump aiowebdav2 to 0.4.5 (#141934) 2025-03-31 18:34:25 +00:00
Michael Hansen
8f9572bb05 Add preannounce boolean for announce/start conversation (#141930)
* Add preannounce boolean

* Fix disabling preannounce in wizard

* Fix casing

* Fix type of preannounce_media_id

* Adjust description of preannounce_media_id
2025-03-31 18:34:22 +00:00
Erik Montnemery
6d022ff4e0 Revert PR 136314 (Cleanup map references in lovelace) (#141928)
* Revert PR 136314 (Cleanup map references in lovelace)

* Update homeassistant/components/lovelace/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Fix dashboard creation

* Update homeassistant/components/lovelace/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-03-31 18:34:19 +00:00
Josef Zweck
c0c2edb90a Add None check to azure_storage (#141922) 2025-03-31 18:34:16 +00:00
Michael
b014219fdd Correct further sensor categorizations in AVM Fritz!Box tools (#141911)
mark margin and attenuation as diagnostic and disable them by default
2025-03-31 18:34:13 +00:00
Joost Lekkerkerker
216b8ef400 Don't create SmartThings entities for disabled components (#141909) 2025-03-31 18:34:10 +00:00
Joost Lekkerkerker
f2ccd46267 Fix SmartThings being able to understand incomplete DRLC (#141907) 2025-03-31 18:34:06 +00:00
Dan Raper
e16ba27ce8 Bump ohmepy to 1.5.1 (#141879)
* Bump ohmepy to 1.5.1

* Fix types for ohmepy version change
2025-03-31 18:34:03 +00:00
Thomas55555
506526a6a2 Handle 403 error in remote calendar (#141839)
* Handle 403 error in remote calendar

* tests
2025-03-31 18:34:00 +00:00
Franck Nijhof
a88678cf42 Fix SmartThings climate entity missing off HAVC mode (#141700)
* Fix smartthing climate entity missing off HAVC mode:

* Fix tests

* Fix test

---------

Co-authored-by: Joostlek <joostlek@outlook.com>
2025-03-31 18:33:57 +00:00
Retha Runolfsson
d0b61af7ec Add switchbot cover unit tests (#140265)
* add cover unit tests

* Add unit test for SwitchBot cover

* fix: use mock_restore_cache to mock the last state

* modify unit tests

* modify scripts as suggest

* improve readability

* adjust patch target per review comments

* adjust patch target per review comments

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
2025-03-31 18:33:53 +00:00
Franck Nijhof
04f5315ab2 Bump version to 2025.4.0b10 2025-03-31 08:09:39 +00:00
Paulus Schoutsen
7f9e4ba39e Ensure user always has first turn for Google Gen AI (#141893) 2025-03-31 08:09:10 +00:00
J. Nick Koston
06aaf188ea Fix duplicate call to async_write_ha_state when adding elkm1 entities (#141890)
When an entity is added state is always written in
add_to_platform_finish:

7336178e03/homeassistant/helpers/entity.py (L1384)

We should not do it in async_added_to_hass as well
2025-03-31 08:09:06 +00:00
J. Nick Koston
627f994872 Bump aioesphomeapi to 29.8.0 (#141888)
changelog: https://github.com/esphome/aioesphomeapi/compare/v29.7.0...v29.8.0
2025-03-31 08:09:03 +00:00
J. Nick Koston
9e81ec5aae Handle encryption being disabled on an ESPHome device (#141887)
fixes #121442
2025-03-31 08:09:00 +00:00
Franck Nijhof
69753fca1d Update pvo to v2.2.1 (#141847) 2025-03-31 08:08:57 +00:00
Michael
7773cc121e Fix the entity category for max throughput sensors in AVM Fritz!Box Tools (#141838)
correct the entity category for max throughput sensors
2025-03-31 08:08:54 +00:00
Michael
3aa56936ad Move setup messages from info to debug level (#141834)
move info to debug level
2025-03-31 08:08:51 +00:00
Franck Nijhof
e66416c23d Fix hardcoded UoM for total power sensor for Tuya zndb devices (#141822) 2025-03-31 08:08:48 +00:00
Jan Bouwhuis
a592feae3d Correct spelling for 'availability` in MQTT translation strings (#141818) 2025-03-31 08:08:45 +00:00
Aidan Timson
fc0d71e891 Fix System Bridge wait timeout wait condition (#141811)
* Fix System Bridge wait timeout wait condition

* Add DataMissingException as a timeout condition

* Add tests
2025-03-31 08:08:42 +00:00
Thomas55555
d4640f1d24 Bump ical to 9.0.3 (#141805) 2025-03-31 08:08:39 +00:00
Michael
6fe158836e Add boost preset to AVM Fritz!SmartHome climate entities (#141802)
* add boost preset to climate entities

* add set boost preset test
2025-03-31 08:08:36 +00:00
J. Nick Koston
629c0087f4 Bump PyISY to 3.1.15 (#141778)
changelog: https://github.com/automicus/PyISY/compare/v3.1.14...v3.1.15

fixes #141517
fixes #132279
2025-03-31 08:08:33 +00:00
J. Nick Koston
363bd75129 Fix blocking late import of httpcore from httpx (#141771)
There is a late import that blocks the event loop
in newer version
9e8ab40369/httpx/_transports/default.py (L75)
2025-03-31 08:08:30 +00:00
J. Nick Koston
7592d350a8 Bump aiohomekit to 3.2.13 (#141764)
changelog: https://github.com/Jc2k/aiohomekit/compare/3.2.8...3.2.13
2025-03-31 08:08:27 +00:00
puddly
8ac8401b4e Add helper methods to simplify USB integration testing (#141733)
* Add some helper methods to simplify USB integration testing

* Re-export `usb_device_from_port`
2025-03-31 08:08:24 +00:00
Joost Lekkerkerker
eed075dbfa Bump pySmartThings to 3.0.1 (#141722) 2025-03-31 08:08:21 +00:00
Florent Thoumie
23dbdedfb6 Bump iaqualink to 0.5.3 (#141709)
* Update to iaqualink 0.5.3 and silence warning

* Update to iaqualink 0.5.3 and silence warning

* Re-add via_device line
2025-03-31 08:08:18 +00:00
Franck Nijhof
85ad29e28e Ensure EcoNet operation modes are unique (#141689) 2025-03-31 08:08:15 +00:00
Michal Schwarz
35fc81b038 Fix order of palettes, presets and playlists in WLED integration (#132207)
* Fix order of palettes, presets and playlists in WLED integration

* fix tests: update palette items order

---------

Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-03-31 08:08:11 +00:00
Lucas Mindêllo de Andrade
5d45b84cd2 Remove sunweg integration (#124230)
* chore(sunweg): remove sunweg integration

* Update homeassistant/components/sunweg/strings.json

Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>

* Update homeassistant/components/sunweg/manifest.json

Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>

* feat: added async remove entry

* Clean setup_entry; add tests

---------

Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: abmantis <amfcalt@gmail.com>
2025-03-31 08:06:54 +00:00
Franck Nijhof
7766649304 Bump version to 2025.4.0b9 2025-03-29 17:50:46 +00:00
Simone Chemelli
07e9020dfa Fix immediate state update for Comelit (#141735) 2025-03-29 17:50:36 +00:00
J. Diego Rodríguez Royo
f504a759e0 Set Home Connect program action field as not required (#141729)
* Set Home Connect program action field as not required

* Remove required field

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-03-29 17:50:32 +00:00
Joost Lekkerkerker
9927de4801 Only trigger events on button updates in SmartThings (#141720)
Only trigger events on button updates
2025-03-29 17:50:29 +00:00
Joost Lekkerkerker
1244fc4682 Only link the parent device if known in SmartThings (#141719)
Only link the parent device if we know the parent device
2025-03-29 17:50:26 +00:00
Norbert Rittel
e77a1b12f7 Sentence-case "Medium type" in mopeka (#141718) 2025-03-29 17:50:22 +00:00
J. Nick Koston
5459daaa10 Fix ESPHome entities not being removed when the ESPHome config removes an entire platform (#141708)
* Fix old ESPHome entities not being removed when configuration changes

fixes #140756

* make sure all callbacks fire

* make sure all callbacks fire

* make sure all callbacks fire

* make sure all callbacks fire

* revert

* cover
2025-03-29 17:50:18 +00:00
J. Nick Koston
400131df78 Fix ESPHome update entities being loaded before device_info is available (#141704)
* Fix ESPHome update entities being loaded before device_info is available

Since we load platforms when restoring config, the update
platform could be loaded before the connection to the
device was finished which meant device_info could still
be empty. Wait until device_info is available to
load the update platform.

fixes #135906

* Apply suggestions from code review

* move comment

* Update entry_data.py

Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>

---------

Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-03-29 17:50:15 +00:00
Franck Nijhof
28e1843ff9 Fix Tuya tdq category to pick up temp & humid (#141698) 2025-03-29 17:50:12 +00:00
Franck Nijhof
df777318d1 Handle invalid JSON errors in AirNow (#141695) 2025-03-29 17:50:08 +00:00
Jan Bouwhuis
6ad5e9e89c Improve MQTT translation strings (#141691)
* Improve MQTT options translation string

* more improvements
2025-03-29 17:50:05 +00:00
Norbert Rittel
a0bd8deee9 Replace "country" with common string in holiday (#141687) 2025-03-29 17:50:01 +00:00
Marcel van der Veldt
405cbd6a00 Always set pause feature on Music Assistant mediaplayers (#141686) 2025-03-29 17:49:58 +00:00
Marcel van der Veldt
3e0eb5ab2c Bump music assistant client to 1.2.0 (#141668)
* Bump music assistant client to 1.2.0

* Update test fixtures
2025-03-29 17:49:55 +00:00
Norbert Rittel
fad75a70b6 Add a common string for "country" (#141653) 2025-03-29 17:49:52 +00:00
Josef Zweck
d9720283df Add unkown to uncalibrated state for tedee (#141262) 2025-03-29 17:49:46 +00:00
Franck Nijhof
14eed1778b Bump version to 2025.4.0b8 2025-03-28 20:46:26 +00:00
Norbert Rittel
049aaa7e8b Fix grammar / sentence-casing in workday (#141682)
* Fix grammar / sentence-casing in `workday`

Also replace "country" with common string.

* Add two more references

* Fix second data description reference

* Add "given" to action description for better translations
2025-03-28 20:46:17 +00:00
J. Nick Koston
35717e8216 Increase websocket_api allowed peak time to 10s (#141680)
* Increase websocket_api allowed peak time to 10s

fixes #141624

During integration reload or startup, we can end up sending a message for
each entity being created for integrations that create them from an external
source (ie MQTT) because the messages come in one at a time. This can overload
the loop and/or client for more than 5s. While we have done significant work
to optimize for this path, we are at the limit at what we can expect clients
to be able to process in the time window, so increase the time window.

* adjust test
2025-03-28 20:46:13 +00:00
Franck Nijhof
2a081abc18 Fix camera proxy with sole image quality settings (#141676) 2025-03-28 20:46:10 +00:00
puddly
b7f29c7358 Handle all firmware types for ZBT-1 and Yellow update entities (#141674)
Handle other firmware types
2025-03-28 20:46:06 +00:00
Jason Hunter
3bb6373df5 Update Duke Energy package to fix integration (#141669)
* Update Duke Energy package to fix integration

* fix tests
2025-03-28 20:46:03 +00:00
Michael Hansen
e1b4edec50 Bump intents and always prefer more literal text (#141663) 2025-03-28 20:46:00 +00:00
puddly
147bee57e1 Include ZBT-1 and Yellow in device registry (#141623)
* Add the Yellow and ZBT-1 to the device registry

* Unload platforms

* Fix unit tests

* Rename the Yellow update entity to `Radio firmware`

* Rename `EmberZNet` to `EmberZNet Zigbee`

* Prefix the `sw_version` with the firmware type and clean up

* Fix unit tests

* Remove unnecessary `always_update=False` from data update coordinator
2025-03-28 20:45:56 +00:00
Erwin Douna
fcdaea64da Tado add proper off state (#135480)
* Add proper off state

* Remove current temp

* Add default frost temp
2025-03-28 20:45:53 +00:00
Franck Nijhof
d1512d46be Bump version to 2025.4.0b7 2025-03-28 16:00:45 +00:00
Bram Kragten
0be7db6270 Update frontend to 20250328.0 (#141659) 2025-03-28 15:09:56 +00:00
Paulus Schoutsen
2af0282725 Enable the message box on default for satelitte announcement actions (#141654) 2025-03-28 15:09:51 +00:00
198 changed files with 7114 additions and 3856 deletions

2
CODEOWNERS generated
View File

@@ -1480,8 +1480,6 @@ build.json @home-assistant/supervisor
/tests/components/suez_water/ @ooii @jb101010-2
/homeassistant/components/sun/ @Swamp-Ig
/tests/components/sun/ @Swamp-Ig
/homeassistant/components/sunweg/ @rokam
/tests/components/sunweg/ @rokam
/homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen

View File

@@ -0,0 +1,5 @@
{
"domain": "eve",
"name": "Eve",
"iot_standards": ["matter"]
}

View File

@@ -8,7 +8,7 @@ from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError
from pyairnow import WebServiceAPI
from pyairnow.conv import aqi_to_concentration
from pyairnow.errors import AirNowError
from pyairnow.errors import AirNowError, InvalidJsonError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -79,7 +79,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
distance=self.distance,
)
except (AirNowError, ClientConnectorError) as error:
except (AirNowError, ClientConnectorError, InvalidJsonError) as error:
raise UpdateFailed(error) from error
if not obs:

View File

@@ -60,7 +60,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{
vol.Optional("message"): str,
vol.Optional("media_id"): str,
vol.Optional("preannounce_media_id"): vol.Any(str, None),
vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): str,
}
),
cv.has_at_least_one_key("message", "media_id"),
@@ -75,7 +76,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{
vol.Optional("start_message"): str,
vol.Optional("start_media_id"): str,
vol.Optional("preannounce_media_id"): vol.Any(str, None),
vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): str,
vol.Optional("extra_system_prompt"): str,
}
),

View File

@@ -180,7 +180,8 @@ class AssistSatelliteEntity(entity.Entity):
self,
message: str | None = None,
media_id: str | None = None,
preannounce_media_id: str | None = PREANNOUNCE_URL,
preannounce: bool = True,
preannounce_media_id: str = PREANNOUNCE_URL,
) -> None:
"""Play and show an announcement on the satellite.
@@ -190,8 +191,8 @@ class AssistSatelliteEntity(entity.Entity):
If media_id is provided, it is played directly. It is possible
to omit the message and the satellite will not show any text.
If preannounce is True, a sound is played before the announcement.
If preannounce_media_id is provided, it overrides the default sound.
If preannounce_media_id is None, no sound is played.
Calls async_announce with message and media id.
"""
@@ -201,7 +202,9 @@ class AssistSatelliteEntity(entity.Entity):
message = ""
announcement = await self._resolve_announcement_media_id(
message, media_id, preannounce_media_id
message,
media_id,
preannounce_media_id=preannounce_media_id if preannounce else None,
)
if self._is_announcing:
@@ -229,7 +232,8 @@ class AssistSatelliteEntity(entity.Entity):
start_message: str | None = None,
start_media_id: str | None = None,
extra_system_prompt: str | None = None,
preannounce_media_id: str | None = PREANNOUNCE_URL,
preannounce: bool = True,
preannounce_media_id: str = PREANNOUNCE_URL,
) -> None:
"""Start a conversation from the satellite.
@@ -239,8 +243,8 @@ class AssistSatelliteEntity(entity.Entity):
If start_media_id is provided, it is played directly. It is possible
to omit the message and the satellite will not show any text.
If preannounce_media_id is provided, it is played before the announcement.
If preannounce_media_id is None, no sound is played.
If preannounce is True, a sound is played before the start message or media.
If preannounce_media_id is provided, it overrides the default sound.
Calls async_start_conversation.
"""
@@ -257,7 +261,9 @@ class AssistSatelliteEntity(entity.Entity):
start_message = ""
announcement = await self._resolve_announcement_media_id(
start_message, start_media_id, preannounce_media_id
start_message,
start_media_id,
preannounce_media_id=preannounce_media_id if preannounce else None,
)
if self._is_announcing:

View File

@@ -8,12 +8,18 @@ announce:
message:
required: false
example: "Time to wake up!"
default: ""
selector:
text:
media_id:
required: false
selector:
text:
preannounce:
required: false
default: true
selector:
boolean:
preannounce_media_id:
required: false
selector:
@@ -28,6 +34,7 @@ start_conversation:
start_message:
required: false
example: "You left the lights on in the living room. Turn them off?"
default: ""
selector:
text:
start_media_id:
@@ -38,6 +45,11 @@ start_conversation:
required: false
selector:
text:
preannounce:
required: false
default: true
selector:
boolean:
preannounce_media_id:
required: false
selector:

View File

@@ -24,9 +24,13 @@
"name": "Media ID",
"description": "The media ID to announce instead of using text-to-speech."
},
"preannounce": {
"name": "Preannounce",
"description": "Play a sound before the announcement."
},
"preannounce_media_id": {
"name": "Preannounce Media ID",
"description": "The media ID to play before the announcement."
"name": "Preannounce media ID",
"description": "Custom media ID to play before the announcement."
}
}
},
@@ -46,9 +50,13 @@
"name": "Extra system prompt",
"description": "Provide background information to the AI about the request."
},
"preannounce": {
"name": "Preannounce",
"description": "Play a sound before the start message or media."
},
"preannounce_media_id": {
"name": "Preannounce Media ID",
"description": "The media ID to play before the start message or media."
"name": "Preannounce media ID",
"description": "Custom media ID to play before the start message or media."
}
}
}

View File

@@ -199,7 +199,7 @@ async def websocket_test_connection(
hass.async_create_background_task(
satellite.async_internal_announce(
media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}",
preannounce_media_id=None,
preannounce=False,
),
f"assist_satellite_connection_test_{msg['entity_id']}",
)

View File

@@ -175,7 +175,8 @@ class AzureStorageBackupAgent(BackupAgent):
"""Find a blob by backup id."""
async for blob in self._client.list_blobs(include="metadata"):
if (
backup_id == blob.metadata.get("backup_id", "")
blob.metadata is not None
and backup_id == blob.metadata.get("backup_id", "")
and blob.metadata.get("metadata_version") == METADATA_VERSION
):
return blob

View File

@@ -501,18 +501,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
return
# presets and inputs might have the same name; presets have priority
url: str | None = None
for input_ in self._inputs:
if input_.text == source:
url = input_.url
await self._player.play_url(input_.url)
return
for preset in self._presets:
if preset.name == source:
url = preset.url
await self._player.load_preset(preset.id)
return
if url is None:
raise ServiceValidationError(f"Source {source} not found")
await self._player.play_url(url)
raise ServiceValidationError(f"Source {source} not found")
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""

View File

@@ -19,7 +19,7 @@
"bleak-retry-connector==3.9.0",
"bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.4.5",
"bluetooth-data-tools==1.26.1",
"bluetooth-data-tools==1.26.5",
"dbus-fast==2.43.0",
"habluetooth==3.37.0"
]

View File

@@ -127,7 +127,11 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
flow_id=flow_id, user_input=tokens
)
self.hass.async_create_task(await_tokens())
# It's a background task because it should be cancelled on shutdown and there's nothing else
# we can do in such case. There's also no need to wait for this during setup.
self.hass.async_create_background_task(
await_tokens(), name="Awaiting OAuth tokens"
)
return authorize_url

View File

@@ -8,7 +8,7 @@ from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -98,13 +98,20 @@ class ComelitCoverEntity(
"""Return if the cover is opening."""
return self._current_action("opening")
async def _cover_set_state(self, action: int, state: int) -> None:
"""Set desired cover state."""
self._last_state = self.state
await self._api.set_device_status(COVER, self._device.index, action)
self.coordinator.data[COVER][self._device.index].status = state
self.async_write_ha_state()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
await self._api.set_device_status(COVER, self._device.index, STATE_OFF)
await self._cover_set_state(STATE_OFF, 2)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open cover."""
await self._api.set_device_status(COVER, self._device.index, STATE_ON)
await self._cover_set_state(STATE_ON, 1)
async def async_stop_cover(self, **_kwargs: Any) -> None:
"""Stop the cover."""
@@ -112,13 +119,7 @@ class ComelitCoverEntity(
return
action = STATE_ON if self.is_closing else STATE_OFF
await self._api.set_device_status(COVER, self._device.index, action)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle device update."""
self._last_state = self.state
self.async_write_ha_state()
await self._cover_set_state(action, 0)
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""

View File

@@ -59,7 +59,8 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity):
async def _light_set_state(self, state: int) -> None:
"""Set desired light state."""
await self.coordinator.api.set_device_status(LIGHT, self._device.index, state)
await self.coordinator.async_request_refresh()
self.coordinator.data[LIGHT][self._device.index].status = state
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""

View File

@@ -67,7 +67,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity):
await self.coordinator.api.set_device_status(
self._device.type, self._device.index, state
)
await self.coordinator.async_request_refresh()
self.coordinator.data[self._device.type][self._device.index].status = state
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""

View File

@@ -650,7 +650,14 @@ class DefaultAgent(ConversationEntity):
if (
(maybe_result is None) # first result
or (num_matched_entities > best_num_matched_entities)
or (
# More literal text matched
result.text_chunks_matched > maybe_result.text_chunks_matched
)
or (
# More entities matched
num_matched_entities > best_num_matched_entities
)
or (
# Fewer unmatched entities
(num_matched_entities == best_num_matched_entities)
@@ -662,16 +669,6 @@ class DefaultAgent(ConversationEntity):
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges > best_num_unmatched_ranges)
)
or (
# More literal text matched
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (
result.text_chunks_matched
> maybe_result.text_chunks_matched
)
)
or (
# Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.24"]
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.28"]
}

View File

@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"],
"requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -7,7 +7,7 @@
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"iot_class": "local_polling",
"requirements": ["async-upnp-client==0.43.0"],
"requirements": ["async-upnp-client==0.44.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",

View File

@@ -50,10 +50,10 @@ class DukeEnergyConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
username = auth["cdp_internal_user_id"].lower()
username = auth["internalUserID"].lower()
await self.async_set_unique_id(username)
self._abort_if_unique_id_configured()
email = auth["email"].lower()
email = auth["loginEmailAddress"].lower()
data = {
CONF_EMAIL: email,
CONF_USERNAME: username,

View File

@@ -6,5 +6,5 @@
"dependencies": ["recorder"],
"documentation": "https://www.home-assistant.io/integrations/duke_energy",
"iot_class": "cloud_polling",
"requirements": ["aiodukeenergy==0.2.2"]
"requirements": ["aiodukeenergy==0.3.0"]
}

View File

@@ -91,15 +91,15 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity):
def operation_list(self) -> list[str]:
"""List of available operation modes."""
econet_modes = self.water_heater.modes
op_list = []
operation_modes = set()
for mode in econet_modes:
if (
mode is not WaterHeaterOperationMode.UNKNOWN
and mode is not WaterHeaterOperationMode.VACATION
):
ha_mode = ECONET_STATE_TO_HA[mode]
op_list.append(ha_mode)
return op_list
operation_modes.add(ha_mode)
return list(operation_modes)
@property
def supported_features(self) -> WaterHeaterEntityFeature:

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.10", "deebot-client==12.4.0"]
"requirements": ["py-sucks==0.9.10", "deebot-client==12.5.0"]
}

View File

@@ -100,7 +100,11 @@ class ElkEntity(Entity):
return {"index": self._element.index + 1}
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
pass
"""Handle changes to the element.
This method is called when the element changes. It should be
overridden by subclasses to handle the changes.
"""
@callback
def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None:
@@ -111,7 +115,7 @@ class ElkEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Register callback for ElkM1 changes and update entity state."""
self._element.add_callback(self._element_callback)
self._element_callback(self._element, {})
self._element_changed(self._element, {})
@property
def device_info(self) -> DeviceInfo:

View File

@@ -128,8 +128,23 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._password = ""
return await self._async_authenticate_or_add()
if error is None and entry_data.get(CONF_NOISE_PSK):
return await self.async_step_reauth_encryption_removed_confirm()
return await self.async_step_reauth_confirm()
async def async_step_reauth_encryption_removed_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthorization flow when encryption was removed."""
if user_input is not None:
self._noise_psk = None
return self._async_get_entry()
return self.async_show_form(
step_id="reauth_encryption_removed_confirm",
description_placeholders={"name": self._name},
)
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

View File

@@ -282,15 +282,18 @@ class RuntimeEntryData:
) -> None:
"""Distribute an update of static infos to all platforms."""
# First, load all platforms
needed_platforms = set()
if async_get_dashboard(hass):
needed_platforms.add(Platform.UPDATE)
needed_platforms: set[Platform] = set()
if self.device_info and self.device_info.voice_assistant_feature_flags_compat(
self.api_version
):
needed_platforms.add(Platform.BINARY_SENSOR)
needed_platforms.add(Platform.SELECT)
if self.device_info:
if async_get_dashboard(hass):
# Only load the update platform if the device_info is set
# When we restore the entry, the device_info may not be set yet
# and we don't want to load the update platform since it needs
# a complete device_info.
needed_platforms.add(Platform.UPDATE)
if self.device_info.voice_assistant_feature_flags_compat(self.api_version):
needed_platforms.add(Platform.BINARY_SENSOR)
needed_platforms.add(Platform.SELECT)
ent_reg = er.async_get(hass)
registry_get_entity = ent_reg.async_get_entity_id
@@ -312,18 +315,19 @@ class RuntimeEntryData:
# Make a dict of the EntityInfo by type and send
# them to the listeners for each specific EntityInfo type
infos_by_type: dict[type[EntityInfo], list[EntityInfo]] = {}
infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict(
list
)
for info in infos:
info_type = type(info)
if info_type not in infos_by_type:
infos_by_type[info_type] = []
infos_by_type[info_type].append(info)
infos_by_type[type(info)].append(info)
callbacks_by_type = self.entity_info_callbacks
for type_, entity_infos in infos_by_type.items():
if callbacks_ := callbacks_by_type.get(type_):
for callback_ in callbacks_:
callback_(entity_infos)
for type_, callbacks in self.entity_info_callbacks.items():
# If all entities for a type are removed, we
# still need to call the callbacks with an empty list
# to make sure the entities are removed.
entity_infos = infos_by_type.get(type_, [])
for callback_ in callbacks:
callback_(entity_infos)
# Finally update static info subscriptions
for callback_ in self.static_info_update_subscriptions:

View File

@@ -13,6 +13,7 @@ from aioesphomeapi import (
APIConnectionError,
APIVersion,
DeviceInfo as EsphomeDeviceInfo,
EncryptionHelloAPIError,
EntityInfo,
HomeassistantServiceCall,
InvalidAuthAPIError,
@@ -570,6 +571,7 @@ class ESPHomeManager:
if isinstance(
err,
(
EncryptionHelloAPIError,
RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError,
InvalidAuthAPIError,

View File

@@ -16,7 +16,7 @@
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"],
"requirements": [
"aioesphomeapi==29.7.0",
"aioesphomeapi==29.8.0",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.12.0"
],

View File

@@ -43,6 +43,9 @@
},
"description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your device configuration."
},
"reauth_encryption_removed_confirm": {
"description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections."
},
"discovery_confirm": {
"description": "Do you want to add the ESPHome node `{name}` to Home Assistant?",
"title": "Discovered ESPHome node"

View File

@@ -193,7 +193,6 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
translation_key="max_kb_s_sent",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_max_kb_s_sent_state,
),
FritzSensorEntityDescription(
@@ -201,7 +200,6 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
translation_key="max_kb_s_received",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_max_kb_s_received_state,
),
FritzSensorEntityDescription(
@@ -225,6 +223,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
translation_key="link_kb_s_sent",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_link_kb_s_sent_state,
),
FritzSensorEntityDescription(
@@ -232,12 +231,15 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
translation_key="link_kb_s_received",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_link_kb_s_received_state,
),
FritzSensorEntityDescription(
key="link_noise_margin_sent",
translation_key="link_noise_margin_sent",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=_retrieve_link_noise_margin_sent_state,
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
),
@@ -245,6 +247,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
key="link_noise_margin_received",
translation_key="link_noise_margin_received",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=_retrieve_link_noise_margin_received_state,
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
),
@@ -252,6 +256,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
key="link_attenuation_sent",
translation_key="link_attenuation_sent",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=_retrieve_link_attenuation_sent_state,
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
),
@@ -259,6 +265,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
key="link_attenuation_received",
translation_key="link_attenuation_received",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=_retrieve_link_attenuation_received_state,
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
),

View File

@@ -6,6 +6,7 @@ from typing import Any
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
ClimateEntity,
@@ -38,7 +39,7 @@ from .sensor import value_scheduled_preset
HVAC_MODES = [HVACMode.HEAT, HVACMode.OFF]
PRESET_HOLIDAY = "holiday"
PRESET_SUMMER = "summer"
PRESET_MODES = [PRESET_ECO, PRESET_COMFORT]
PRESET_MODES = [PRESET_ECO, PRESET_COMFORT, PRESET_BOOST]
SUPPORTED_FEATURES = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
@@ -194,6 +195,8 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
return PRESET_HOLIDAY
if self.data.summer_active:
return PRESET_SUMMER
if self.data.target_temperature == ON_API_TEMPERATURE:
return PRESET_BOOST
if self.data.target_temperature == self.data.comfort_temperature:
return PRESET_COMFORT
if self.data.target_temperature == self.data.eco_temperature:
@@ -211,6 +214,8 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
await self.async_set_temperature(temperature=self.data.comfort_temperature)
elif preset_mode == PRESET_ECO:
await self.async_set_temperature(temperature=self.data.eco_temperature)
elif preset_mode == PRESET_BOOST:
await self.async_set_temperature(temperature=ON_REPORT_SET_TEMPERATURE)
@property
def extra_state_attributes(self) -> ClimateExtraAttributes:

View File

@@ -137,6 +137,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = (
key="battery",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suitable=lambda device: device.battery_level is not None,
native_value=lambda device: device.battery_level,

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250327.1"]
"requirements": ["home-assistant-frontend==20250401.0"]
}

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.0.1"]
"requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.0.3"]
}

View File

@@ -7,7 +7,7 @@ import logging
from types import MappingProxyType
from typing import Any
from google import genai # type: ignore[attr-defined]
from google import genai
from google.genai.errors import APIError, ClientError
from requests.exceptions import Timeout
import voluptuous as vol

View File

@@ -356,6 +356,15 @@ class GoogleGenerativeAIConversationEntity(
messages.append(_convert_content(chat_content))
# The SDK requires the first message to be a user message
# This is not the case if user used `start_conversation`
# Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537
if messages and messages[0].role != "user":
messages.insert(
0,
Content(role="user", parts=[Part.from_text(text=" ")]),
)
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
generateContentConfig = GenerateContentConfig(

View File

@@ -8,7 +8,7 @@
"step": {
"user": {
"data": {
"country": "Country"
"country": "[%key:common::config_flow::data::country%]"
}
},
"options": {

View File

@@ -64,7 +64,6 @@ set_program_and_options:
- selected_program
program:
example: dishcare_dishwasher_program_auto2
required: true
selector:
select:
mode: dropdown

View File

@@ -31,7 +31,6 @@ class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]):
_LOGGER,
name="firmware update coordinator",
update_interval=FIRMWARE_REFRESH_INTERVAL,
always_update=False,
)
self.hass = hass
self.session = session

View File

@@ -33,6 +33,7 @@ from .util import (
OwningIntegration,
get_otbr_addon_manager,
get_zigbee_flasher_addon_manager,
guess_firmware_info,
guess_hardware_owners,
probe_silabs_firmware_info,
)
@@ -511,6 +512,16 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm a discovery."""
assert self._device is not None
fw_info = await guess_firmware_info(self.hass, self._device)
# If our guess for the firmware type is actually running, we can save the user
# an unnecessary confirmation and silently confirm the flow
for owner in fw_info.owners:
if await owner.is_running(self.hass):
self._probed_firmware_info = fw_info
return self._async_flow_finished()
return await self.async_step_pick_firmware()

View File

@@ -95,8 +95,7 @@ class BaseFirmwareUpdateEntity(
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
)
# Until this entity can be associated with a device, we must manually name it
_attr_has_entity_name = False
_attr_has_entity_name = True
def __init__(
self,
@@ -195,11 +194,7 @@ class BaseFirmwareUpdateEntity(
def _update_attributes(self) -> None:
"""Recompute the attributes of the entity."""
# This entity is not currently associated with a device so we must manually
# give it a name
self._attr_name = f"{self._config_entry.title} Update"
self._attr_title = self.entity_description.firmware_name or "unknown"
self._attr_title = self.entity_description.firmware_name or "Unknown"
if (
self._current_firmware_info is None

View File

@@ -3,19 +3,79 @@
from __future__ import annotations
import logging
import os.path
from homeassistant.components.homeassistant_hardware.util import guess_firmware_info
from homeassistant.components.usb import (
USBDevice,
async_register_port_event_callback,
scan_serial_ports,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DESCRIPTION, DEVICE, FIRMWARE, FIRMWARE_VERSION, PRODUCT
from .const import (
DESCRIPTION,
DEVICE,
DOMAIN,
FIRMWARE,
FIRMWARE_VERSION,
MANUFACTURER,
PID,
PRODUCT,
SERIAL_NUMBER,
VID,
)
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the ZBT-1 integration."""
@callback
def async_port_event_callback(
added: set[USBDevice], removed: set[USBDevice]
) -> None:
"""Handle USB port events."""
current_entries_by_path = {
entry.data[DEVICE]: entry
for entry in hass.config_entries.async_entries(DOMAIN)
}
for device in added | removed:
path = device.device
entry = current_entries_by_path.get(path)
if entry is not None:
_LOGGER.debug(
"Device %r has changed state, reloading config entry %s",
path,
entry,
)
hass.config_entries.async_schedule_reload(entry.entry_id)
async_register_port_event_callback(hass, async_port_event_callback)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Home Assistant SkyConnect config entry."""
# Postpone loading the config entry if the device is missing
device_path = entry.data[DEVICE]
if not await hass.async_add_executor_job(os.path.exists, device_path):
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_disconnected",
)
await hass.config_entries.async_forward_entry_setups(entry, ["update"])
return True
@@ -23,6 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
await hass.config_entries.async_unload_platforms(entry, ["update"])
return True
@@ -30,7 +91,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
"""Migrate old entry."""
_LOGGER.debug(
"Migrating from version %s:%s", config_entry.version, config_entry.minor_version
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
)
if config_entry.version == 1:
@@ -65,6 +126,43 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
minor_version=3,
)
if config_entry.minor_version == 3:
# Old SkyConnect config entries were missing keys
if any(
key not in config_entry.data
for key in (VID, PID, MANUFACTURER, PRODUCT, SERIAL_NUMBER)
):
serial_ports = await hass.async_add_executor_job(scan_serial_ports)
serial_ports_info = {port.device: port for port in serial_ports}
device = config_entry.data[DEVICE]
if not (usb_info := serial_ports_info.get(device)):
raise HomeAssistantError(
f"USB device {device} is missing, cannot migrate"
)
hass.config_entries.async_update_entry(
config_entry,
data={
**config_entry.data,
VID: usb_info.vid,
PID: usb_info.pid,
MANUFACTURER: usb_info.manufacturer,
PRODUCT: usb_info.description,
DESCRIPTION: usb_info.description,
SERIAL_NUMBER: usb_info.serial_number,
},
version=1,
minor_version=4,
)
else:
# Existing entries are migrated by just incrementing the version
hass.config_entries.async_update_entry(
config_entry,
version=1,
minor_version=4,
)
_LOGGER.debug(
"Migration to version %s.%s successful",
config_entry.version,

View File

@@ -81,7 +81,7 @@ class HomeAssistantSkyConnectConfigFlow(
"""Handle a config flow for Home Assistant SkyConnect."""
VERSION = 1
MINOR_VERSION = 3
MINOR_VERSION = 4
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the config flow."""

View File

@@ -195,5 +195,10 @@
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
}
},
"exceptions": {
"device_disconnected": {
"message": "The device is not plugged in"
}
}
}

View File

@@ -21,11 +21,20 @@ from homeassistant.components.update import UpdateDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import FIRMWARE, FIRMWARE_VERSION, NABU_CASA_FIRMWARE_RELEASES_URL
from .const import (
DOMAIN,
FIRMWARE,
FIRMWARE_VERSION,
NABU_CASA_FIRMWARE_RELEASES_URL,
PRODUCT,
SERIAL_NUMBER,
HardwareVariant,
)
_LOGGER = logging.getLogger(__name__)
@@ -42,7 +51,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
fw_type="skyconnect_zigbee_ncp",
version_key="ezsp_version",
expected_firmware_type=ApplicationType.EZSP,
firmware_name="EmberZNet",
firmware_name="EmberZNet Zigbee",
),
ApplicationType.SPINEL: FirmwareUpdateEntityDescription(
key="firmware",
@@ -55,6 +64,28 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
expected_firmware_type=ApplicationType.SPINEL,
firmware_name="OpenThread RCP",
),
ApplicationType.CPC: FirmwareUpdateEntityDescription(
key="firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
version_parser=lambda fw: fw,
fw_type="skyconnect_multipan",
version_key="cpc_version",
expected_firmware_type=ApplicationType.CPC,
firmware_name="Multiprotocol",
),
ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription(
key="firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
version_parser=lambda fw: fw,
fw_type=None, # We don't want to update the bootloader
version_key="gecko_bootloader_version",
expected_firmware_type=ApplicationType.GECKO_BOOTLOADER,
firmware_name="Gecko Bootloader",
),
None: FirmwareUpdateEntityDescription(
key="firmware",
display_precision=0,
@@ -77,9 +108,16 @@ def _async_create_update_entity(
) -> FirmwareUpdateEntity:
"""Create an update entity that handles firmware type changes."""
firmware_type = config_entry.data[FIRMWARE]
entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[
ApplicationType(firmware_type) if firmware_type is not None else None
]
try:
entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[
ApplicationType(firmware_type)
]
except (KeyError, ValueError):
_LOGGER.debug(
"Unknown firmware type %r, using default entity description", firmware_type
)
entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[None]
entity = FirmwareUpdateEntity(
device=config_entry.data["device"],
@@ -141,8 +179,18 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Initialize the SkyConnect firmware update entity."""
super().__init__(device, config_entry, update_coordinator, entity_description)
self._attr_unique_id = (
f"{self._config_entry.data['serial_number']}_{self.entity_description.key}"
variant = HardwareVariant.from_usb_product_name(
self._config_entry.data[PRODUCT]
)
serial_number = self._config_entry.data[SERIAL_NUMBER]
self._attr_unique_id = f"{serial_number}_{self.entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
name=f"{variant.full_name} ({serial_number[:8]})",
model=variant.full_name,
manufacturer="Nabu Casa",
serial_number=serial_number,
)
# Use the cached firmware info if it exists
@@ -155,6 +203,17 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
source="homeassistant_sky_connect",
)
def _update_attributes(self) -> None:
"""Recompute the attributes of the entity."""
super()._update_attributes()
assert self.device_entry is not None
device_registry = dr.async_get(self.hass)
device_registry.async_update_device(
device_id=self.device_entry.id,
sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}",
)
@callback
def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None:
"""Handle updated firmware info being pushed by an integration."""

View File

@@ -62,6 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
await hass.config_entries.async_unload_platforms(entry, ["update"])
return True

View File

@@ -2,8 +2,9 @@
DOMAIN = "homeassistant_yellow"
RADIO_MODEL = "Home Assistant Yellow"
RADIO_MANUFACTURER = "Nabu Casa"
MODEL = "Home Assistant Yellow"
MANUFACTURER = "Nabu Casa"
RADIO_DEVICE = "/dev/ttyAMA1"
ZHA_HW_DISCOVERY_DATA = {

View File

@@ -149,5 +149,12 @@
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
}
},
"entity": {
"update": {
"radio_firmware": {
"name": "Radio firmware"
}
}
}
}

View File

@@ -21,13 +21,17 @@ from homeassistant.components.update import UpdateDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
FIRMWARE,
FIRMWARE_VERSION,
MANUFACTURER,
MODEL,
NABU_CASA_FIRMWARE_RELEASES_URL,
RADIO_DEVICE,
)
@@ -39,7 +43,8 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
ApplicationType | None, FirmwareUpdateEntityDescription
] = {
ApplicationType.EZSP: FirmwareUpdateEntityDescription(
key="firmware",
key="radio_firmware",
translation_key="radio_firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
@@ -47,10 +52,11 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
fw_type="yellow_zigbee_ncp",
version_key="ezsp_version",
expected_firmware_type=ApplicationType.EZSP,
firmware_name="EmberZNet",
firmware_name="EmberZNet Zigbee",
),
ApplicationType.SPINEL: FirmwareUpdateEntityDescription(
key="firmware",
key="radio_firmware",
translation_key="radio_firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
@@ -60,8 +66,33 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
expected_firmware_type=ApplicationType.SPINEL,
firmware_name="OpenThread RCP",
),
ApplicationType.CPC: FirmwareUpdateEntityDescription(
key="radio_firmware",
translation_key="radio_firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
version_parser=lambda fw: fw,
fw_type="yellow_multipan",
version_key="cpc_version",
expected_firmware_type=ApplicationType.CPC,
firmware_name="Multiprotocol",
),
ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription(
key="radio_firmware",
translation_key="radio_firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
version_parser=lambda fw: fw,
fw_type=None, # We don't want to update the bootloader
version_key="gecko_bootloader_version",
expected_firmware_type=ApplicationType.GECKO_BOOTLOADER,
firmware_name="Gecko Bootloader",
),
None: FirmwareUpdateEntityDescription(
key="firmware",
key="radio_firmware",
translation_key="radio_firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
@@ -82,9 +113,16 @@ def _async_create_update_entity(
) -> FirmwareUpdateEntity:
"""Create an update entity that handles firmware type changes."""
firmware_type = config_entry.data[FIRMWARE]
entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[
ApplicationType(firmware_type) if firmware_type is not None else None
]
try:
entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[
ApplicationType(firmware_type)
]
except (KeyError, ValueError):
_LOGGER.debug(
"Unknown firmware type %r, using default entity description", firmware_type
)
entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[None]
entity = FirmwareUpdateEntity(
device=RADIO_DEVICE,
@@ -145,8 +183,13 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
) -> None:
"""Initialize the Yellow firmware update entity."""
super().__init__(device, config_entry, update_coordinator, entity_description)
self._attr_unique_id = self.entity_description.key
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, "yellow")},
name=MODEL,
model=MODEL,
manufacturer=MANUFACTURER,
)
# Use the cached firmware info if it exists
if self._config_entry.data[FIRMWARE] is not None:
@@ -158,6 +201,17 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
source="homeassistant_yellow",
)
def _update_attributes(self) -> None:
"""Recompute the attributes of the entity."""
super()._update_attributes()
assert self.device_entry is not None
device_registry = dr.async_get(self.hass)
device_registry.async_update_device(
device_id=self.device_entry.id,
sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}",
)
@callback
def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None:
"""Handle updated firmware info being pushed by an integration."""

View File

@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
"requirements": ["aiohomekit==3.2.8"],
"requirements": ["aiohomekit==3.2.13"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}

View File

@@ -197,5 +197,11 @@
}
}
}
},
"issues": {
"deprecated_effect_none": {
"title": "Light turned on with deprecated effect",
"description": "A light was turned on with the deprecated effect `None`. This has been replaced with `off`. Please update any automations, scenes, or scripts that use this effect."
}
}
}

View File

@@ -29,6 +29,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.util import color as color_util
from ..bridge import HueBridge
@@ -44,6 +45,9 @@ FALLBACK_MIN_KELVIN = 6500
FALLBACK_MAX_KELVIN = 2000
FALLBACK_KELVIN = 5800 # halfway
# HA 2025.4 replaced the deprecated effect "None" with HA default "off"
DEPRECATED_EFFECT_NONE = "None"
async def async_setup_entry(
hass: HomeAssistant,
@@ -233,6 +237,23 @@ class HueLight(HueBaseEntity, LightEntity):
self._color_temp_active = color_temp is not None
flash = kwargs.get(ATTR_FLASH)
effect = effect_str = kwargs.get(ATTR_EFFECT)
if effect_str == DEPRECATED_EFFECT_NONE:
# deprecated effect "None" is now "off"
effect_str = EFFECT_OFF
async_create_issue(
self.hass,
DOMAIN,
"deprecated_effect_none",
breaks_in_ha_version="2025.10.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_effect_none",
)
self.logger.warning(
"Detected deprecated effect 'None' in %s, use 'off' instead. "
"This will stop working in HA 2025.10",
self.entity_id,
)
if effect_str == EFFECT_OFF:
# ignore effect if set to "off" and we have no effect active
# the special effect "off" is only used to stop an active effect

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/iaqualink",
"iot_class": "cloud_polling",
"loggers": ["iaqualink"],
"requirements": ["iaqualink==0.5.0", "h2==4.1.0"],
"requirements": ["iaqualink==0.5.3", "h2==4.1.0"],
"single_config_entry": true
}

View File

@@ -138,7 +138,7 @@ async def async_setup_entry(
for vtype, _, vid in isy.variables.children:
numbers.append(isy.variables[vtype][vid])
if (
isy.conf[CONFIG_NETWORKING] or isy.conf[CONFIG_PORTAL]
isy.conf[CONFIG_NETWORKING] or isy.conf.get(CONFIG_PORTAL)
) and isy.networking.nobjs:
isy_data.devices[CONF_NETWORK] = _create_service_device_info(
isy, name=CONFIG_NETWORKING, unique_id=CONF_NETWORK

View File

@@ -24,7 +24,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyisy"],
"requirements": ["pyisy==3.1.14"],
"requirements": ["pyisy==3.1.15"],
"ssdp": [
{
"manufacturer": "Universal Devices Inc.",

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.26.1", "ld2410-ble==0.1.1"]
"requirements": ["bluetooth-data-tools==1.26.5", "ld2410-ble==0.1.1"]
}

View File

@@ -35,5 +35,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.26.1", "led-ble==1.1.6"]
"requirements": ["bluetooth-data-tools==1.26.5", "led-ble==1.1.6"]
}

View File

@@ -63,10 +63,12 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Add a callback to handle core config update.
self.unit_system: str | None = None
self.hass.bus.async_listen(
event_type=EVENT_CORE_CONFIG_UPDATE,
listener=self._handle_update_config,
event_filter=self.async_config_update_filter,
self.config_entry.async_on_unload(
self.hass.bus.async_listen(
event_type=EVENT_CORE_CONFIG_UPDATE,
listener=self._handle_update_config,
event_filter=self.async_config_update_filter,
)
)
async def _handle_update_config(self, _: Event) -> None:

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],
"requirements": ["ical==9.0.1"]
"requirements": ["ical==9.0.3"]
}

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling",
"requirements": ["ical==9.0.1"]
"requirements": ["ical==9.0.3"]
}

View File

@@ -6,7 +6,7 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import frontend, websocket_api
from homeassistant.components import frontend, onboarding, websocket_api
from homeassistant.config import (
async_hass_config_yaml,
async_process_component_and_handle_errors,
@@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.frame import report_usage
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.translation import async_get_translations
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration
from homeassistant.util import slugify
@@ -282,6 +283,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
STORAGE_DASHBOARD_UPDATE_FIELDS,
).async_setup(hass)
def create_map_dashboard() -> None:
"""Create a map dashboard."""
hass.async_create_task(_create_map_dashboard(hass, dashboards_collection))
if not onboarding.async_is_onboarded(hass):
onboarding.async_add_listener(hass, create_map_dashboard)
return True
@@ -323,3 +331,25 @@ def _register_panel(
kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON)
frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs)
async def _create_map_dashboard(
hass: HomeAssistant, dashboards_collection: dashboard.DashboardsCollection
) -> None:
"""Create a map dashboard."""
translations = await async_get_translations(
hass, hass.config.language, "dashboard", {onboarding.DOMAIN}
)
title = translations["component.onboarding.dashboard.map.title"]
await dashboards_collection.async_create_item(
{
CONF_ALLOW_SINGLE_WORD: True,
CONF_ICON: "mdi:map",
CONF_TITLE: title,
CONF_URL_PATH: "map",
}
)
map_store = hass.data[LOVELACE_DATA].dashboards["map"]
await map_store.async_save({"strategy": {"type": "map"}})

View File

@@ -6,7 +6,7 @@
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]",
"medium_type": "Medium Type"
"medium_type": "Medium type"
}
},
"bluetooth_confirm": {

View File

@@ -62,6 +62,7 @@ from ..entity import MqttEntity
from ..models import (
MqttCommandTemplate,
MqttValueTemplate,
PayloadSentinel,
PublishPayloadType,
ReceiveMessage,
)
@@ -126,7 +127,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
_command_templates: dict[
str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType]
]
_value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]]
_value_templates: dict[
str, Callable[[ReceivePayloadType, ReceivePayloadType], ReceivePayloadType]
]
_fixed_color_mode: ColorMode | str | None
_topics: dict[str, str | None]
@@ -203,73 +206,133 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
@callback
def _state_received(self, msg: ReceiveMessage) -> None:
"""Handle new MQTT messages."""
state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload)
if state == STATE_ON:
state_value = self._value_templates[CONF_STATE_TEMPLATE](
msg.payload,
PayloadSentinel.NONE,
)
if not state_value:
_LOGGER.debug(
"Ignoring message from '%s' with empty state value", msg.topic
)
elif state_value == STATE_ON:
self._attr_is_on = True
elif state == STATE_OFF:
elif state_value == STATE_OFF:
self._attr_is_on = False
elif state == PAYLOAD_NONE:
elif state_value == PAYLOAD_NONE:
self._attr_is_on = None
else:
_LOGGER.warning("Invalid state value received")
_LOGGER.warning(
"Invalid state value '%s' received from %s",
state_value,
msg.topic,
)
if CONF_BRIGHTNESS_TEMPLATE in self._config:
try:
if brightness := int(
self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload)
):
self._attr_brightness = brightness
else:
_LOGGER.debug(
"Ignoring zero brightness value for entity %s",
self.entity_id,
brightness_value = self._value_templates[CONF_BRIGHTNESS_TEMPLATE](
msg.payload,
PayloadSentinel.NONE,
)
if not brightness_value:
_LOGGER.debug(
"Ignoring message from '%s' with empty brightness value",
msg.topic,
)
else:
try:
if brightness := int(brightness_value):
self._attr_brightness = brightness
else:
_LOGGER.debug(
"Ignoring zero brightness value for entity %s",
self.entity_id,
)
except ValueError:
_LOGGER.warning(
"Invalid brightness value '%s' received from %s",
brightness_value,
msg.topic,
)
except ValueError:
_LOGGER.warning("Invalid brightness value received from %s", msg.topic)
if CONF_COLOR_TEMP_TEMPLATE in self._config:
try:
color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE](
msg.payload
color_temp_value = self._value_templates[CONF_COLOR_TEMP_TEMPLATE](
msg.payload,
PayloadSentinel.NONE,
)
if not color_temp_value:
_LOGGER.debug(
"Ignoring message from '%s' with empty color temperature value",
msg.topic,
)
self._attr_color_temp_kelvin = (
int(color_temp)
if self._color_temp_kelvin
else color_util.color_temperature_mired_to_kelvin(int(color_temp))
if color_temp != "None"
else None
)
except ValueError:
_LOGGER.warning("Invalid color temperature value received")
else:
try:
self._attr_color_temp_kelvin = (
int(color_temp_value)
if self._color_temp_kelvin
else color_util.color_temperature_mired_to_kelvin(
int(color_temp_value)
)
if color_temp_value != "None"
else None
)
except ValueError:
_LOGGER.warning(
"Invalid color temperature value '%s' received from %s",
color_temp_value,
msg.topic,
)
if (
CONF_RED_TEMPLATE in self._config
and CONF_GREEN_TEMPLATE in self._config
and CONF_BLUE_TEMPLATE in self._config
):
try:
red = self._value_templates[CONF_RED_TEMPLATE](msg.payload)
green = self._value_templates[CONF_GREEN_TEMPLATE](msg.payload)
blue = self._value_templates[CONF_BLUE_TEMPLATE](msg.payload)
if red == "None" and green == "None" and blue == "None":
self._attr_hs_color = None
else:
self._attr_hs_color = color_util.color_RGB_to_hs(
int(red), int(green), int(blue)
)
red_value = self._value_templates[CONF_RED_TEMPLATE](
msg.payload,
PayloadSentinel.NONE,
)
green_value = self._value_templates[CONF_GREEN_TEMPLATE](
msg.payload,
PayloadSentinel.NONE,
)
blue_value = self._value_templates[CONF_BLUE_TEMPLATE](
msg.payload,
PayloadSentinel.NONE,
)
if not red_value or not green_value or not blue_value:
_LOGGER.debug(
"Ignoring message from '%s' with empty color value", msg.topic
)
elif red_value == "None" and green_value == "None" and blue_value == "None":
self._attr_hs_color = None
self._update_color_mode()
except ValueError:
_LOGGER.warning("Invalid color value received")
else:
try:
self._attr_hs_color = color_util.color_RGB_to_hs(
int(red_value), int(green_value), int(blue_value)
)
self._update_color_mode()
except ValueError:
_LOGGER.warning("Invalid color value received from %s", msg.topic)
if CONF_EFFECT_TEMPLATE in self._config:
effect = str(self._value_templates[CONF_EFFECT_TEMPLATE](msg.payload))
if (
effect_list := self._config[CONF_EFFECT_LIST]
) and effect in effect_list:
self._attr_effect = effect
effect_value = self._value_templates[CONF_EFFECT_TEMPLATE](
msg.payload,
PayloadSentinel.NONE,
)
if not effect_value:
_LOGGER.debug(
"Ignoring message from '%s' with empty effect value", msg.topic
)
elif (effect_list := self._config[CONF_EFFECT_LIST]) and str(
effect_value
) in effect_list:
self._attr_effect = str(effect_value)
else:
_LOGGER.warning("Unsupported effect value received")
_LOGGER.warning(
"Unsupported effect value '%s' received from %s",
effect_value,
msg.topic,
)
@callback
def _prepare_subscribe_topics(self) -> None:

View File

@@ -126,7 +126,7 @@
"payload_not_available": "Payload not available"
},
"data_description": {
"availability_topic": "Topic to receive the availabillity payload on",
"availability_topic": "Topic to receive the availability payload on",
"availability_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-templates-with-the-mqtt-integration) to render the availability payload received on the availability topic",
"payload_available": "The payload that indicates the device is available (defaults to 'online')",
"payload_not_available": "The payload that indicates the device is not available (defaults to 'offline')"
@@ -219,10 +219,10 @@
"options": "Add option"
},
"data_description": {
"device_class": "The device class of the {platform} entity. [Learn more.]({url}#device_class)",
"state_class": "The [state_class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)",
"device_class": "The Device class of the {platform} entity. [Learn more.]({url}#device_class)",
"state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)",
"unit_of_measurement": "Defines the unit of measurement of the sensor, if any.",
"options": "Options for allowed sensor state values. The sensors device_class must be set to Enumeration. The options option cannot be used together with State Class or Unit of measurement."
"options": "Options for allowed sensor state values. The sensors Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement."
},
"sections": {
"advanced_settings": {

View File

@@ -26,7 +26,7 @@ from . import subscription
from .config import DEFAULT_RETAIN, MQTT_RO_SCHEMA
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON
from .entity import MqttEntity, async_setup_entity_entry_helper
from .models import MqttValueTemplate, ReceiveMessage
from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic, valid_subscribe_topic
@@ -136,7 +136,18 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity):
@callback
def _handle_state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle receiving state message via MQTT."""
payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload)
payload = self._templates[CONF_VALUE_TEMPLATE](
msg.payload, PayloadSentinel.DEFAULT
)
if payload is PayloadSentinel.DEFAULT:
_LOGGER.warning(
"Unable to process payload '%s' for topic %s, with value template '%s'",
msg.payload,
msg.topic,
self._config.get(CONF_VALUE_TEMPLATE),
)
return
if not payload or payload == PAYLOAD_EMPTY_JSON:
_LOGGER.debug(

View File

@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push",
"loggers": ["music_assistant"],
"requirements": ["music-assistant-client==1.1.1"],
"requirements": ["music-assistant-client==1.2.0"],
"zeroconf": ["_mass._tcp.local."]
}

View File

@@ -94,6 +94,12 @@ SUPPORTED_FEATURES_BASE = (
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.MEDIA_ANNOUNCE
| MediaPlayerEntityFeature.SEEK
# we always add pause support,
# regardless if the underlying player actually natively supports pause
# because the MA behavior is to internally handle pause with stop
# (and a resume position) and we'd like to keep the UX consistent
# background info: https://github.com/home-assistant/core/issues/140118
| MediaPlayerEntityFeature.PAUSE
)
QUEUE_OPTION_MAP = {
@@ -697,8 +703,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
supported_features = SUPPORTED_FEATURES_BASE
if PlayerFeature.SET_MEMBERS in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.GROUPING
if PlayerFeature.PAUSE in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.PAUSE
if self.player.mute_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
if self.player.volume_control != PLAYER_CONTROL_NONE:

View File

@@ -34,7 +34,7 @@ def validate_prices(
index: int,
) -> float | None:
"""Validate and return."""
if result := func(entity)[area][index]:
if (result := func(entity)[area][index]) is not None:
return result / 1000
return None

View File

@@ -2,8 +2,9 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from ohme import ApiException, ChargerStatus, OhmeApiClient
@@ -23,7 +24,7 @@ PARALLEL_UPDATES = 1
class OhmeButtonDescription(OhmeEntityDescription, ButtonEntityDescription):
"""Class describing Ohme button entities."""
press_fn: Callable[[OhmeApiClient], Awaitable[None]]
press_fn: Callable[[OhmeApiClient], Coroutine[Any, Any, bool]]
BUTTON_DESCRIPTIONS = [

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["ohme==1.4.1"]
"requirements": ["ohme==1.5.1"]
}

View File

@@ -1,7 +1,8 @@
"""Platform for number."""
from collections.abc import Awaitable, Callable
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from ohme import ApiException, OhmeApiClient
@@ -22,7 +23,7 @@ PARALLEL_UPDATES = 1
class OhmeNumberDescription(OhmeEntityDescription, NumberEntityDescription):
"""Class describing Ohme number entities."""
set_fn: Callable[[OhmeApiClient, float], Awaitable[None]]
set_fn: Callable[[OhmeApiClient, float], Coroutine[Any, Any, bool]]
value_fn: Callable[[OhmeApiClient], float]
@@ -31,7 +32,7 @@ NUMBER_DESCRIPTION = [
key="target_percentage",
translation_key="target_percentage",
value_fn=lambda client: client.target_soc,
set_fn=lambda client, value: client.async_set_target(target_percent=value),
set_fn=lambda client, value: client.async_set_target(target_percent=int(value)),
native_min_value=0,
native_max_value=100,
native_step=1,
@@ -42,7 +43,7 @@ NUMBER_DESCRIPTION = [
translation_key="preconditioning_duration",
value_fn=lambda client: client.preconditioning,
set_fn=lambda client, value: client.async_set_target(
pre_condition_length=value
pre_condition_length=int(value)
),
native_min_value=0,
native_max_value=60,

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, Final
@@ -24,7 +24,7 @@ PARALLEL_UPDATES = 1
class OhmeSelectDescription(OhmeEntityDescription, SelectEntityDescription):
"""Class to describe an Ohme select entity."""
select_fn: Callable[[OhmeApiClient, Any], Awaitable[None]]
select_fn: Callable[[OhmeApiClient, Any], Coroutine[Any, Any, bool | None]]
options: list[str] | None = None
options_fn: Callable[[OhmeApiClient], list[str]] | None = None
current_option_fn: Callable[[OhmeApiClient], str | None]

View File

@@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0
class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription):
"""Class describing Ohme sensor entities."""
value_fn: Callable[[OhmeApiClient], str | int | float]
value_fn: Callable[[OhmeApiClient], str | int | float | None]
SENSOR_CHARGE_SESSION = [
@@ -129,6 +129,6 @@ class OhmeSensor(OhmeEntity, SensorEntity):
entity_description: OhmeSensorDescription
@property
def native_value(self) -> str | int | float:
def native_value(self) -> str | int | float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.client)

View File

@@ -78,7 +78,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
"""List of charge slots."""
client = __get_client(service_call)
return {"slots": client.slots}
return {"slots": [slot.to_dict() for slot in client.slots]}
async def set_price_cap(
service_call: ServiceCall,

View File

@@ -1,8 +1,9 @@
"""Platform for time."""
from collections.abc import Awaitable, Callable
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from datetime import time
from typing import Any
from ohme import ApiException, OhmeApiClient
@@ -22,7 +23,7 @@ PARALLEL_UPDATES = 1
class OhmeTimeDescription(OhmeEntityDescription, TimeEntityDescription):
"""Class describing Ohme time entities."""
set_fn: Callable[[OhmeApiClient, time], Awaitable[None]]
set_fn: Callable[[OhmeApiClient, time], Coroutine[Any, Any, bool]]
value_fn: Callable[[OhmeApiClient], time]

View File

@@ -31,7 +31,7 @@ from homeassistant.helpers import area_registry as ar
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.helpers.translation import async_get_translations
from homeassistant.setup import async_setup_component
from homeassistant.setup import SetupPhases, async_pause_setup, async_setup_component
if TYPE_CHECKING:
from . import OnboardingData, OnboardingStorage, OnboardingStoreData
@@ -60,7 +60,7 @@ async def async_setup(
hass.http.register_view(BackupInfoView(data))
hass.http.register_view(RestoreBackupView(data))
hass.http.register_view(UploadBackupView(data))
setup_cloud_views(hass, data)
await setup_cloud_views(hass, data)
class OnboardingView(HomeAssistantView):
@@ -430,9 +430,19 @@ class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView):
return await self._post(request)
def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None:
async def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None:
"""Set up the cloud views."""
with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES):
# Import the cloud integration in an executor to avoid blocking the
# event loop.
def import_cloud() -> None:
"""Import the cloud integration."""
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.cloud import http_api # noqa: F401
await hass.async_add_import_executor_job(import_cloud)
# The cloud integration is imported locally to avoid cloud being imported by
# bootstrap.py and to avoid circular imports.

View File

@@ -88,8 +88,8 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]):
),
translation_key=key,
translation_placeholders={
"total": str(drive.quota.total),
"used": str(drive.quota.used),
"total": f"{drive.quota.total / (1024**3):.2f}",
"used": f"{drive.quota.used / (1024**3):.2f}",
},
)
return drive

View File

@@ -6,5 +6,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/private_ble_device",
"iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.26.1"]
"requirements": ["bluetooth-data-tools==1.26.5"]
}

View File

@@ -104,6 +104,15 @@ def _resize_image(image, opts):
new_width = opts.max_width
(old_width, old_height) = img.size
old_size = len(image)
# If no max_width specified, only apply quality changes if requested
if new_width is None:
if opts.quality is None:
return image
imgbuf = io.BytesIO()
img.save(imgbuf, "JPEG", optimize=True, quality=quality)
return imgbuf.getvalue()
if old_width <= new_width:
if opts.quality is None:
_LOGGER.debug("Image is smaller-than/equal-to requested width")

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/pvoutput",
"integration_type": "device",
"iot_class": "cloud_polling",
"requirements": ["pvo==2.2.0"]
"requirements": ["pvo==2.2.1"]
}

View File

@@ -1,5 +1,6 @@
"""Config flow for Remote Calendar integration."""
from http import HTTPStatus
import logging
from typing import Any
@@ -50,6 +51,13 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
client = get_async_client(self.hass)
try:
res = await client.get(user_input[CONF_URL], follow_redirects=True)
if res.status_code == HTTPStatus.FORBIDDEN:
errors["base"] = "forbidden"
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
res.raise_for_status()
except (HTTPError, InvalidURL) as err:
errors["base"] = "cannot_connect"

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["ical"],
"quality_scale": "silver",
"requirements": ["ical==9.0.1"]
"requirements": ["ical==9.0.3"]
}

View File

@@ -19,6 +19,7 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"forbidden": "The server understood the request but refuses to authorize it.",
"invalid_ics_file": "[%key:component::local_calendar::config::error::invalid_ics_file%]"
}
},

View File

@@ -39,7 +39,7 @@
"samsungctl[websocket]==0.7.1",
"samsungtvws[async,encrypted]==2.7.2",
"wakeonlan==2.1.0",
"async-upnp-client==0.43.0"
"async-upnp-client==0.44.0"
],
"ssdp": [
{

View File

@@ -426,7 +426,7 @@ def create_devices(
kwargs[ATTR_CONNECTIONS] = {
(dr.CONNECTION_NETWORK_MAC, device.device.hub.mac_address)
}
if device.device.parent_device_id:
if device.device.parent_device_id and device.device.parent_device_id in devices:
kwargs[ATTR_VIA_DEVICE] = (DOMAIN, device.device.parent_device_id)
if (ocf := device.device.ocf) is not None:
kwargs.update(
@@ -478,7 +478,27 @@ def process_status(status: dict[str, ComponentStatus]) -> dict[str, ComponentSta
if (main_component := status.get(MAIN)) is None:
return status
if (
disabled_capabilities_capability := main_component.get(
disabled_components_capability := main_component.get(
Capability.CUSTOM_DISABLED_COMPONENTS
)
) is not None:
disabled_components = cast(
list[str],
disabled_components_capability[Attribute.DISABLED_COMPONENTS].value,
)
if disabled_components is not None:
for component in disabled_components:
if component in status:
del status[component]
for component_status in status.values():
process_component_status(component_status)
return status
def process_component_status(status: ComponentStatus) -> None:
"""Remove disabled capabilities from component status."""
if (
disabled_capabilities_capability := status.get(
Capability.CUSTOM_DISABLED_CAPABILITIES
)
) is not None:
@@ -488,9 +508,8 @@ def process_status(status: dict[str, ComponentStatus]) -> dict[str, ComponentSta
)
if disabled_capabilities is not None:
for capability in disabled_capabilities:
if capability in main_component and (
if capability in status and (
capability not in KEEP_CAPABILITY_QUIRK
or not KEEP_CAPABILITY_QUIRK[capability](main_component[capability])
or not KEEP_CAPABILITY_QUIRK[capability](status[capability])
):
del main_component[capability]
return status
del status[capability]

View File

@@ -7,26 +7,21 @@ from dataclasses import dataclass
from pysmartthings import Attribute, Capability, Category, SmartThings, Status
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.components.script import scripts_with_entity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from . import FullDevice, SmartThingsConfigEntry
from .const import DOMAIN, MAIN
from .const import INVALID_SWITCH_CATEGORIES, MAIN
from .entity import SmartThingsEntity
from .util import deprecate_entity
@dataclass(frozen=True, kw_only=True)
@@ -132,14 +127,7 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.SWITCH,
device_class=BinarySensorDeviceClass.POWER,
is_on_key="on",
category={
Category.CLOTHING_CARE_MACHINE,
Category.COOKTOP,
Category.DISHWASHER,
Category.DRYER,
Category.MICROWAVE,
Category.WASHER,
},
category=INVALID_SWITCH_CATEGORIES,
)
},
Capability.TAMPER_ALERT: {
@@ -192,24 +180,64 @@ async def async_setup_entry(
) -> None:
"""Add binary sensors for a config entry."""
entry_data = entry.runtime_data
async_add_entities(
SmartThingsBinarySensor(
entry_data.client, device, description, capability, attribute, component
)
for device in entry_data.devices.values()
for capability, attribute_map in CAPABILITY_TO_SENSORS.items()
for attribute, description in attribute_map.items()
for component in device.status
if capability in device.status[component]
and (
component == MAIN
or (description.exists_fn is not None and description.exists_fn(component))
)
and (
not description.category
or get_main_component_category(device) in description.category
)
)
entities = []
entity_registry = er.async_get(hass)
for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks
for capability, attribute_map in CAPABILITY_TO_SENSORS.items():
for attribute, description in attribute_map.items():
for component in device.status:
if (
capability in device.status[component]
and (
component == MAIN
or (
description.exists_fn is not None
and description.exists_fn(component)
)
)
and (
not description.category
or get_main_component_category(device)
in description.category
)
):
if (
component == MAIN
and (issue := description.deprecated_fn(device.status))
is not None
):
if deprecate_entity(
hass,
entity_registry,
BINARY_SENSOR_DOMAIN,
f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}",
f"deprecated_binary_{issue}",
):
entities.append(
SmartThingsBinarySensor(
entry_data.client,
device,
description,
capability,
attribute,
component,
)
)
continue
entities.append(
SmartThingsBinarySensor(
entry_data.client,
device,
description,
capability,
attribute,
component,
)
)
async_add_entities(entities)
class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity):
@@ -257,57 +285,3 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity):
self.get_attribute_value(self.capability, self._attribute)
== self.entity_description.is_on_key
)
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
await super().async_added_to_hass()
if (issue := self.entity_description.deprecated_fn(self.device.status)) is None:
return
automations = automations_with_entity(self.hass, self.entity_id)
scripts = scripts_with_entity(self.hass, self.entity_id)
items = automations + scripts
if not items:
return
entity_reg: er.EntityRegistry = er.async_get(self.hass)
entity_automations = [
automation_entity
for automation_id in automations
if (automation_entity := entity_reg.async_get(automation_id))
]
entity_scripts = [
script_entity
for script_id in scripts
if (script_entity := entity_reg.async_get(script_id))
]
items_list = [
f"- [{item.original_name}](/config/automation/edit/{item.unique_id})"
for item in entity_automations
] + [
f"- [{item.original_name}](/config/script/edit/{item.unique_id})"
for item in entity_scripts
]
async_create_issue(
self.hass,
DOMAIN,
f"deprecated_binary_{issue}_{self.entity_id}",
breaks_in_ha_version="2025.10.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=f"deprecated_binary_{issue}",
translation_placeholders={
"entity": self.entity_id,
"items": "\n".join(items_list),
},
)
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if (issue := self.entity_description.deprecated_fn(self.device.status)) is None:
return
async_delete_issue(
self.hass, DOMAIN, f"deprecated_binary_{issue}_{self.entity_id}"
)

View File

@@ -281,7 +281,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity):
return [
state
for mode in supported_thermostat_modes
if (state := AC_MODE_TO_STATE.get(mode)) is not None
if (state := MODE_TO_STATE.get(mode)) is not None
]
@property
@@ -466,12 +466,14 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
Capability.DEMAND_RESPONSE_LOAD_CONTROL,
Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS,
)
return {
"drlc_status_duration": drlc_status["duration"],
"drlc_status_level": drlc_status["drlcLevel"],
"drlc_status_start": drlc_status["start"],
"drlc_status_override": drlc_status["override"],
}
res = {}
for key in ("duration", "start", "override", "drlcLevel"):
if key in drlc_status:
dict_key = {"drlcLevel": "drlc_status_level"}.get(
key, f"drlc_status_{key}"
)
res[dict_key] = drlc_status[key]
return res
@property
def fan_mode(self) -> str:

View File

@@ -1,6 +1,6 @@
"""Constants used by the SmartThings component and platforms."""
from pysmartthings import Attribute, Capability
from pysmartthings import Attribute, Capability, Category
DOMAIN = "smartthings"
@@ -109,3 +109,12 @@ SENSOR_ATTRIBUTES_TO_CAPABILITIES: dict[str, str] = {
Attribute.WASHER_MODE: Capability.WASHER_MODE,
Attribute.WASHER_JOB_STATE: Capability.WASHER_OPERATING_STATE,
}
INVALID_SWITCH_CATEGORIES = {
Category.CLOTHING_CARE_MACHINE,
Category.COOKTOP,
Category.DRYER,
Category.WASHER,
Category.MICROWAVE,
Category.DISHWASHER,
}

View File

@@ -58,5 +58,6 @@ class SmartThingsButtonEvent(SmartThingsEntity, EventEntity):
)
def _update_handler(self, event: DeviceEvent) -> None:
self._trigger_event(cast(str, event.value))
self.async_write_ha_state()
if event.attribute is Attribute.BUTTON:
self._trigger_event(cast(str, event.value))
super()._update_handler(event)

View File

@@ -30,5 +30,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.0.0"]
"requirements": ["pysmartthings==3.0.1"]
}

View File

@@ -9,9 +9,8 @@ from typing import Any, cast
from pysmartthings import Attribute, Capability, ComponentStatus, SmartThings, Status
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -33,16 +32,12 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.util import dt as dt_util
from . import FullDevice, SmartThingsConfigEntry
from .const import DOMAIN, MAIN
from .const import MAIN
from .entity import SmartThingsEntity
from .util import deprecate_entity
THERMOSTAT_CAPABILITIES = {
Capability.TEMPERATURE_MEASUREMENT,
@@ -1021,31 +1016,67 @@ async def async_setup_entry(
) -> None:
"""Add sensors for a config entry."""
entry_data = entry.runtime_data
async_add_entities(
SmartThingsSensor(
entry_data.client,
device,
description,
capability,
attribute,
)
for device in entry_data.devices.values()
for capability, attributes in CAPABILITY_TO_SENSORS.items()
if capability in device.status[MAIN]
for attribute, descriptions in attributes.items()
for description in descriptions
if (
not description.capability_ignore_list
or not any(
all(capability in device.status[MAIN] for capability in capability_list)
for capability_list in description.capability_ignore_list
)
)
and (
not description.exists_fn
or description.exists_fn(device.status[MAIN][capability][attribute])
)
)
entities = []
entity_registry = er.async_get(hass)
for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks
for capability, attributes in CAPABILITY_TO_SENSORS.items():
if capability in device.status[MAIN]:
for attribute, descriptions in attributes.items():
for description in descriptions:
if (
not description.capability_ignore_list
or not any(
all(
capability in device.status[MAIN]
for capability in capability_list
)
for capability_list in description.capability_ignore_list
)
) and (
not description.exists_fn
or description.exists_fn(
device.status[MAIN][capability][attribute]
)
):
if (
description.deprecated
and (
reason := description.deprecated(
device.status[MAIN]
)
)
is not None
):
if deprecate_entity(
hass,
entity_registry,
SENSOR_DOMAIN,
f"{device.device.device_id}_{MAIN}_{capability}_{attribute}_{description.key}",
f"deprecated_{reason}",
):
entities.append(
SmartThingsSensor(
entry_data.client,
device,
description,
capability,
attribute,
)
)
continue
entities.append(
SmartThingsSensor(
entry_data.client,
device,
description,
capability,
attribute,
)
)
async_add_entities(entities)
class SmartThingsSensor(SmartThingsEntity, SensorEntity):
@@ -1113,53 +1144,3 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity):
return []
return [option.lower() for option in options]
return super().options
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
await super().async_added_to_hass()
if (
not self.entity_description.deprecated
or (reason := self.entity_description.deprecated(self.device.status[MAIN]))
is None
):
return
automations = automations_with_entity(self.hass, self.entity_id)
scripts = scripts_with_entity(self.hass, self.entity_id)
if not automations and not scripts:
return
entity_reg: er.EntityRegistry = er.async_get(self.hass)
items_list = [
f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})"
for integration, entities in (
("automation", automations),
("script", scripts),
)
for entity_id in entities
if (item := entity_reg.async_get(entity_id))
]
async_create_issue(
self.hass,
DOMAIN,
f"deprecated_{reason}_{self.entity_id}",
breaks_in_ha_version="2025.10.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=f"deprecated_{reason}",
translation_placeholders={
"entity": self.entity_id,
"items": "\n".join(items_list),
},
)
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if (
not self.entity_description.deprecated
or (reason := self.entity_description.deprecated(self.device.status[MAIN]))
is None
):
return
async_delete_issue(self.hass, DOMAIN, f"deprecated_{reason}_{self.entity_id}")

View File

@@ -480,24 +480,44 @@
},
"issues": {
"deprecated_binary_valve": {
"title": "Deprecated valve binary sensor detected in some automations or scripts",
"description": "The valve binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts to fix this issue."
"title": "Valve binary sensor deprecated",
"description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. A valve entity with controls is available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue."
},
"deprecated_binary_valve_scripts": {
"title": "[%key:component::smartthings::issues::deprecated_binary_valve::title%]",
"description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts and disable the entity to fix this issue."
},
"deprecated_binary_fridge_door": {
"title": "Deprecated refrigerator door binary sensor detected in some automations or scripts",
"description": "The refrigerator door binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts to fix this issue."
"title": "Refrigerator door binary sensor deprecated",
"description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. Separate entities for cooler and freezer door are available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue."
},
"deprecated_binary_fridge_door_scripts": {
"title": "[%key:component::smartthings::issues::deprecated_binary_fridge_door::title%]",
"description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts and disable the entity to fix this issue."
},
"deprecated_switch_appliance": {
"title": "Deprecated switch detected in some automations or scripts",
"description": "The switch `{entity}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts to fix this issue."
"title": "Appliance switch deprecated",
"description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nPlease update your dashboards, templates accordingly and disable the entity to fix this issue."
},
"deprecated_switch_appliance_scripts": {
"title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]",
"description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts and disable the entity to fix this issue."
},
"deprecated_switch_media_player": {
"title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]",
"description": "The switch `{entity}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts to fix this issue."
"description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue."
},
"deprecated_switch_media_player_scripts": {
"title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]",
"description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue."
},
"deprecated_media_player": {
"title": "Media player sensors deprecated",
"description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nPlease update your dashboards, templates to use the new media player entity and disable the entity to fix this issue."
},
"deprecated_media_player_scripts": {
"title": "Deprecated sensor detected in some automations or scripts",
"description": "The sensor `{entity}` is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts to fix this issue."
"description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new media player entity and disable the entity to fix this issue."
}
}
}

View File

@@ -5,23 +5,21 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from pysmartthings import Attribute, Capability, Category, Command, SmartThings
from pysmartthings import Attribute, Capability, Command, SmartThings
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from . import FullDevice, SmartThingsConfigEntry
from .const import DOMAIN, MAIN
from .const import INVALID_SWITCH_CATEGORIES, MAIN
from .entity import SmartThingsEntity
from .util import deprecate_entity
CAPABILITIES = (
Capability.SWITCH_LEVEL,
@@ -37,6 +35,12 @@ AC_CAPABILITIES = (
Capability.THERMOSTAT_COOLING_SETPOINT,
)
MEDIA_PLAYER_CAPABILITIES = (
Capability.AUDIO_MUTE,
Capability.AUDIO_VOLUME,
Capability.MEDIA_PLAYBACK,
)
@dataclass(frozen=True, kw_only=True)
class SmartThingsSwitchEntityDescription(SwitchEntityDescription):
@@ -92,13 +96,6 @@ async def async_setup_entry(
"""Add switches for a config entry."""
entry_data = entry.runtime_data
entities: list[SmartThingsEntity] = [
SmartThingsSwitch(entry_data.client, device, SWITCH, Capability.SWITCH)
for device in entry_data.devices.values()
if Capability.SWITCH in device.status[MAIN]
and not any(capability in device.status[MAIN] for capability in CAPABILITIES)
and not all(capability in device.status[MAIN] for capability in AC_CAPABILITIES)
]
entities.extend(
SmartThingsCommandSwitch(
entry_data.client,
device,
@@ -108,7 +105,7 @@ async def async_setup_entry(
for device in entry_data.devices.values()
for capability, description in CAPABILITY_TO_COMMAND_SWITCHES.items()
if capability in device.status[MAIN]
)
]
entities.extend(
SmartThingsSwitch(
entry_data.client,
@@ -129,6 +126,51 @@ async def async_setup_entry(
)
)
)
entity_registry = er.async_get(hass)
for device in entry_data.devices.values():
if (
Capability.SWITCH in device.status[MAIN]
and not any(
capability in device.status[MAIN] for capability in CAPABILITIES
)
and not all(
capability in device.status[MAIN] for capability in AC_CAPABILITIES
)
):
media_player = all(
capability in device.status[MAIN]
for capability in MEDIA_PLAYER_CAPABILITIES
)
appliance = (
device.device.components[MAIN].manufacturer_category
in INVALID_SWITCH_CATEGORIES
)
if media_player or appliance:
issue = "media_player" if media_player else "appliance"
if deprecate_entity(
hass,
entity_registry,
SWITCH_DOMAIN,
f"{device.device.device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}",
f"deprecated_switch_{issue}",
):
entities.append(
SmartThingsSwitch(
entry_data.client,
device,
SWITCH,
Capability.SWITCH,
)
)
continue
entities.append(
SmartThingsSwitch(
entry_data.client,
device,
SWITCH,
Capability.SWITCH,
)
)
async_add_entities(entities)
@@ -136,7 +178,6 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity):
"""Define a SmartThings switch."""
entity_description: SmartThingsSwitchEntityDescription
created_issue: bool = False
def __init__(
self,
@@ -182,70 +223,6 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity):
== "on"
)
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
await super().async_added_to_hass()
media_player = all(
capability in self.device.status[MAIN]
for capability in (
Capability.AUDIO_MUTE,
Capability.AUDIO_VOLUME,
Capability.MEDIA_PLAYBACK,
)
)
if (
self.entity_description != SWITCH
and self.device.device.components[MAIN].manufacturer_category
not in {
Category.CLOTHING_CARE_MACHINE,
Category.COOKTOP,
Category.DRYER,
Category.WASHER,
Category.MICROWAVE,
Category.DISHWASHER,
}
) or (self.entity_description != SWITCH and not media_player):
return
automations = automations_with_entity(self.hass, self.entity_id)
scripts = scripts_with_entity(self.hass, self.entity_id)
if not automations and not scripts:
return
entity_reg: er.EntityRegistry = er.async_get(self.hass)
items_list = [
f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})"
for integration, entities in (
("automation", automations),
("script", scripts),
)
for entity_id in entities
if (item := entity_reg.async_get(entity_id))
]
identifier = "media_player" if media_player else "appliance"
self.created_issue = True
async_create_issue(
self.hass,
DOMAIN,
f"deprecated_switch_{self.entity_id}",
breaks_in_ha_version="2025.10.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=f"deprecated_switch_{identifier}",
translation_placeholders={
"entity": self.entity_id,
"items": "\n".join(items_list),
},
)
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.created_issue:
return
async_delete_issue(self.hass, DOMAIN, f"deprecated_switch_{self.entity_id}")
class SmartThingsCommandSwitch(SmartThingsSwitch):
"""Define a SmartThings command switch."""

View File

@@ -0,0 +1,83 @@
"""Utility functions for SmartThings integration."""
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from .const import DOMAIN
def deprecate_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
platform_domain: str,
entity_unique_id: str,
issue_string: str,
) -> bool:
"""Create an issue for deprecated entities."""
if entity_id := entity_registry.async_get_entity_id(
platform_domain, DOMAIN, entity_unique_id
):
entity_entry = entity_registry.async_get(entity_id)
if not entity_entry:
return False
if entity_entry.disabled:
entity_registry.async_remove(entity_id)
async_delete_issue(
hass,
DOMAIN,
f"{issue_string}_{entity_id}",
)
return False
translation_key = issue_string
placeholders = {
"entity_id": entity_id,
"entity_name": entity_entry.name or entity_entry.original_name or "Unknown",
}
if items := get_automations_and_scripts_using_entity(hass, entity_id):
translation_key = f"{translation_key}_scripts"
placeholders.update(
{
"items": "\n".join(items),
}
)
async_create_issue(
hass,
DOMAIN,
f"{issue_string}_{entity_id}",
breaks_in_ha_version="2025.10.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=translation_key,
translation_placeholders=placeholders,
)
return True
return False
def get_automations_and_scripts_using_entity(
hass: HomeAssistant,
entity_id: str,
) -> list[str]:
"""Get automations and scripts using an entity."""
automations = automations_with_entity(hass, entity_id)
scripts = scripts_with_entity(hass, entity_id)
if not automations and not scripts:
return []
entity_reg = er.async_get(hass)
return [
f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})"
for integration, entities in (
("automation", automations),
("script", scripts),
)
for entity_id in entities
if (item := entity_reg.async_get(entity_id))
]

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"quality_scale": "internal",
"requirements": ["async-upnp-client==0.43.0"]
"requirements": ["async-upnp-client==0.44.0"]
}

View File

@@ -1,197 +1,39 @@
"""The Sun WEG inverter sensor integration."""
import datetime
import json
import logging
from sunweg.api import APIHelper
from sunweg.plant import Plant
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.typing import StateType, UndefinedType
from homeassistant.util import Throttle
from homeassistant.helpers import issue_registry as ir
from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS, DeviceType
SCAN_INTERVAL = datetime.timedelta(minutes=5)
_LOGGER = logging.getLogger(__name__)
DOMAIN = "sunweg"
async def async_setup_entry(
hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> bool:
async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
"""Load the saved entities."""
api = APIHelper(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD])
if not await hass.async_add_executor_job(api.authenticate):
raise ConfigEntryAuthFailed("Username or Password may be incorrect!")
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SunWEGData(
api, entry.data[CONF_PLANT_ID]
ir.async_create_issue(
hass,
DOMAIN,
DOMAIN,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="integration_removed",
translation_placeholders={
"issue": "https://github.com/rokam/sunweg/issues/13",
"entries": "/config/integrations/integration/sunweg",
},
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
hass.data[DOMAIN].pop(entry.entry_id)
if len(hass.data[DOMAIN]) == 0:
hass.data.pop(DOMAIN)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
return True
class SunWEGData:
"""The class for handling data retrieval."""
def __init__(
self,
api: APIHelper,
plant_id: int,
) -> None:
"""Initialize the probe."""
self.api = api
self.plant_id = plant_id
self.data: Plant = None
self.previous_values: dict = {}
@Throttle(SCAN_INTERVAL)
def update(self) -> None:
"""Update probe data."""
_LOGGER.debug("Updating data for plant %s", self.plant_id)
try:
self.data = self.api.plant(self.plant_id)
for inverter in self.data.inverters:
self.api.complete_inverter(inverter)
except json.decoder.JSONDecodeError:
_LOGGER.error("Unable to fetch data from SunWEG server")
_LOGGER.debug("Finished updating data for plant %s", self.plant_id)
def get_api_value(
self,
variable: str,
device_type: DeviceType,
inverter_id: int = 0,
deep_name: str | None = None,
):
"""Retrieve from a Plant the desired variable value."""
if device_type == DeviceType.TOTAL:
return self.data.__dict__.get(variable)
inverter_list = [i for i in self.data.inverters if i.id == inverter_id]
if len(inverter_list) == 0:
return None
inverter = inverter_list[0]
if device_type == DeviceType.INVERTER:
return inverter.__dict__.get(variable)
if device_type == DeviceType.PHASE:
for phase in inverter.phases:
if phase.name == deep_name:
return phase.__dict__.get(variable)
elif device_type == DeviceType.STRING:
for mppt in inverter.mppts:
for string in mppt.strings:
if string.name == deep_name:
return string.__dict__.get(variable)
return None
def get_data(
self,
*,
api_variable_key: str,
api_variable_unit: str | None,
deep_name: str | None,
device_type: DeviceType,
inverter_id: int,
name: str | UndefinedType | None,
native_unit_of_measurement: str | None,
never_resets: bool,
previous_value_drop_threshold: float | None,
) -> tuple[StateType | datetime.datetime, str | None]:
"""Get the data."""
_LOGGER.debug(
"Data request for: %s",
name,
)
variable = api_variable_key
previous_unit = native_unit_of_measurement
api_value = self.get_api_value(variable, device_type, inverter_id, deep_name)
previous_value = self.previous_values.get(variable)
return_value = api_value
if api_variable_unit is not None:
native_unit_of_measurement = self.get_api_value(
api_variable_unit,
device_type,
inverter_id,
deep_name,
)
# If we have a 'drop threshold' specified, then check it and correct if needed
if (
previous_value_drop_threshold is not None
and previous_value is not None
and api_value is not None
and previous_unit == native_unit_of_measurement
):
_LOGGER.debug(
(
"%s - Drop threshold specified (%s), checking for drop... API"
" Value: %s, Previous Value: %s"
),
name,
previous_value_drop_threshold,
api_value,
previous_value,
)
diff = float(api_value) - float(previous_value)
# Check if the value has dropped (negative value i.e. < 0) and it has only
# dropped by a small amount, if so, use the previous value.
# Note - The energy dashboard takes care of drops within 10%
# of the current value, however if the value is low e.g. 0.2
# and drops by 0.1 it classes as a reset.
if -(previous_value_drop_threshold) <= diff < 0:
_LOGGER.debug(
(
"Diff is negative, but only by a small amount therefore not a"
" nightly reset, using previous value (%s) instead of api value"
" (%s)"
),
previous_value,
api_value,
)
return_value = previous_value
else:
_LOGGER.debug("%s - No drop detected, using API value", name)
# Lifetime total values should always be increasing, they will never reset,
# however the API sometimes returns 0 values when the clock turns to 00:00
# local time in that scenario we should just return the previous value
# Scenarios:
# 1 - System has a genuine 0 value when it it first commissioned:
# - will return 0 until a non-zero value is registered
# 2 - System has been running fine but temporarily resets to 0 briefly
# at midnight:
# - will return the previous value
# 3 - HA is restarted during the midnight 'outage' - Not handled:
# - Previous value will not exist meaning 0 will be returned
# - This is an edge case that would be better handled by looking
# up the previous value of the entity from the recorder
if never_resets and api_value == 0 and previous_value:
_LOGGER.debug(
(
"API value is 0, but this value should never reset, returning"
" previous value (%s) instead"
),
previous_value,
)
return_value = previous_value
self.previous_values[variable] = return_value
return (return_value, native_unit_of_measurement)
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))

View File

@@ -1,129 +1,11 @@
"""Config flow for Sun WEG integration."""
from collections.abc import Mapping
from typing import Any
from homeassistant.config_entries import ConfigFlow
from sunweg.api import APIHelper, SunWegApiError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from .const import CONF_PLANT_ID, DOMAIN
from . import DOMAIN
class SunWEGConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow class."""
VERSION = 1
def __init__(self) -> None:
"""Initialise sun weg server flow."""
self.api: APIHelper = None
self.data: dict[str, Any] = {}
@callback
def _async_show_user_form(self, step_id: str, errors=None) -> ConfigFlowResult:
"""Show the form to the user."""
default_username = ""
if CONF_USERNAME in self.data:
default_username = self.data[CONF_USERNAME]
data_schema = vol.Schema(
{
vol.Required(CONF_USERNAME, default=default_username): str,
vol.Required(CONF_PASSWORD): str,
}
)
return self.async_show_form(
step_id=step_id, data_schema=data_schema, errors=errors
)
def _set_auth_data(
self, step: str, username: str, password: str
) -> ConfigFlowResult | None:
"""Set username and password."""
if self.api:
# Set username and password
self.api.username = username
self.api.password = password
else:
# Initialise the library with the username & password
self.api = APIHelper(username, password)
try:
if not self.api.authenticate():
return self._async_show_user_form(step, {"base": "invalid_auth"})
except SunWegApiError:
return self._async_show_user_form(step, {"base": "timeout_connect"})
return None
async def async_step_user(self, user_input=None) -> ConfigFlowResult:
"""Handle the start of the config flow."""
if not user_input:
return self._async_show_user_form("user")
# Store authentication info
self.data = user_input
conf_result = await self.hass.async_add_executor_job(
self._set_auth_data,
"user",
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
return await self.async_step_plant() if conf_result is None else conf_result
async def async_step_plant(self, user_input=None) -> ConfigFlowResult:
"""Handle adding a "plant" to Home Assistant."""
plant_list = await self.hass.async_add_executor_job(self.api.listPlants)
if len(plant_list) == 0:
return self.async_abort(reason="no_plants")
plants = {plant.id: plant.name for plant in plant_list}
if user_input is None and len(plant_list) > 1:
data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)})
return self.async_show_form(step_id="plant", data_schema=data_schema)
if user_input is None and len(plant_list) == 1:
user_input = {CONF_PLANT_ID: plant_list[0].id}
user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]]
await self.async_set_unique_id(user_input[CONF_PLANT_ID])
self._abort_if_unique_id_configured()
self.data.update(user_input)
return self.async_create_entry(title=self.data[CONF_NAME], data=self.data)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthorization request from SunWEG."""
self.data.update(entry_data)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthorization flow."""
if user_input is None:
return self._async_show_user_form("reauth_confirm")
self.data.update(user_input)
conf_result = await self.hass.async_add_executor_job(
self._set_auth_data,
"reauth_confirm",
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
if conf_result is not None:
return conf_result
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=self.data
)

View File

@@ -1,25 +0,0 @@
"""Define constants for the Sun WEG component."""
from enum import Enum
from homeassistant.const import Platform
class DeviceType(Enum):
"""Device Type Enum."""
TOTAL = 1
INVERTER = 2
PHASE = 3
STRING = 4
CONF_PLANT_ID = "plant_id"
DEFAULT_PLANT_ID = 0
DEFAULT_NAME = "Sun WEG"
DOMAIN = "sunweg"
PLATFORMS = [Platform.SENSOR]

View File

@@ -1,10 +1,10 @@
{
"domain": "sunweg",
"name": "Sun WEG",
"codeowners": ["@rokam"],
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sunweg",
"iot_class": "cloud_polling",
"loggers": ["sunweg"],
"requirements": ["sunweg==3.0.2"]
"loggers": [],
"requirements": []
}

View File

@@ -1,178 +0,0 @@
"""Read status of SunWEG inverters."""
from __future__ import annotations
import logging
from types import MappingProxyType
from typing import Any
from sunweg.api import APIHelper
from sunweg.device import Inverter
from sunweg.plant import Plant
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .. import SunWEGData
from ..const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType
from .inverter import INVERTER_SENSOR_TYPES
from .phase import PHASE_SENSOR_TYPES
from .sensor_entity_description import SunWEGSensorEntityDescription
from .string import STRING_SENSOR_TYPES
from .total import TOTAL_SENSOR_TYPES
_LOGGER = logging.getLogger(__name__)
def get_device_list(
api: APIHelper, config: MappingProxyType[str, Any]
) -> tuple[list[Inverter], int]:
"""Retrieve the device list for the selected plant."""
plant_id = int(config[CONF_PLANT_ID])
if plant_id == DEFAULT_PLANT_ID:
plant_info: list[Plant] = api.listPlants()
plant_id = plant_info[0].id
devices: list[Inverter] = []
# Get a list of devices for specified plant to add sensors for.
for inverter in api.plant(plant_id).inverters:
api.complete_inverter(inverter)
devices.append(inverter)
return (devices, plant_id)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SunWEG sensor."""
name = config_entry.data[CONF_NAME]
probe: SunWEGData = hass.data[DOMAIN][config_entry.entry_id]
devices, plant_id = await hass.async_add_executor_job(
get_device_list, probe.api, config_entry.data
)
entities = [
SunWEGInverter(
probe,
name=f"{name} Total",
unique_id=f"{plant_id}-{description.key}",
description=description,
device_type=DeviceType.TOTAL,
)
for description in TOTAL_SENSOR_TYPES
]
# Add sensors for each device in the specified plant.
entities.extend(
[
SunWEGInverter(
probe,
name=f"{device.name}",
unique_id=f"{device.sn}-{description.key}",
description=description,
device_type=DeviceType.INVERTER,
inverter_id=device.id,
)
for device in devices
for description in INVERTER_SENSOR_TYPES
]
)
entities.extend(
[
SunWEGInverter(
probe,
name=f"{device.name} {phase.name}",
unique_id=f"{device.sn}-{phase.name}-{description.key}",
description=description,
inverter_id=device.id,
device_type=DeviceType.PHASE,
deep_name=phase.name,
)
for device in devices
for phase in device.phases
for description in PHASE_SENSOR_TYPES
]
)
entities.extend(
[
SunWEGInverter(
probe,
name=f"{device.name} {string.name}",
unique_id=f"{device.sn}-{string.name}-{description.key}",
description=description,
inverter_id=device.id,
device_type=DeviceType.STRING,
deep_name=string.name,
)
for device in devices
for mppt in device.mppts
for string in mppt.strings
for description in STRING_SENSOR_TYPES
]
)
async_add_entities(entities, True)
class SunWEGInverter(SensorEntity):
"""Representation of a SunWEG Sensor."""
entity_description: SunWEGSensorEntityDescription
def __init__(
self,
probe: SunWEGData,
name: str,
unique_id: str,
description: SunWEGSensorEntityDescription,
device_type: DeviceType,
inverter_id: int = 0,
deep_name: str | None = None,
) -> None:
"""Initialize a sensor."""
self.probe = probe
self.entity_description = description
self.device_type = device_type
self.inverter_id = inverter_id
self.deep_name = deep_name
self._attr_name = f"{name} {description.name}"
self._attr_unique_id = unique_id
self._attr_icon = (
description.icon if description.icon is not None else "mdi:solar-power"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(probe.plant_id))},
manufacturer="SunWEG",
name=name,
)
def update(self) -> None:
"""Get the latest data from the Sun WEG API and updates the state."""
self.probe.update()
(
self._attr_native_value,
self._attr_native_unit_of_measurement,
) = self.probe.get_data(
api_variable_key=self.entity_description.api_variable_key,
api_variable_unit=self.entity_description.api_variable_unit,
deep_name=self.deep_name,
device_type=self.device_type,
inverter_id=self.inverter_id,
name=self.entity_description.name,
native_unit_of_measurement=self.native_unit_of_measurement,
never_resets=self.entity_description.never_resets,
previous_value_drop_threshold=self.entity_description.previous_value_drop_threshold,
)

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