Compare commits

..

136 Commits

Author SHA1 Message Date
Robert Resch
d20568e83c Copy go2rtc binary from offical docker image 2025-04-02 16:48:14 +02:00
Marcel van der Veldt
0871bf13a4 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 15:39:31 +02:00
puddly
4c44d2f4d9 Translation key for ZBT-1 integration failing due to disconnection (#142077)
Translation key for device disconnected
2025-04-02 15:33:41 +02:00
Joost Lekkerkerker
833a8be2d1 Improve SmartThings switch deprecation (#142072) 2025-04-02 15:33:17 +02:00
Abílio Costa
f8113ae80b 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 15:07:00 +02:00
Erik Montnemery
feff5355c8 Mark Integration with @final (#142057) 2025-04-02 15:05:43 +02:00
Erik Montnemery
6fbee5c2e3 Mark ReadOnlyDict with @final (#142059) 2025-04-02 14:06:01 +02:00
Erik Montnemery
8200c234dd Mark logbook.EventAsRow with @final (#142058) 2025-04-02 14:05:23 +02:00
Erik Montnemery
dfd86d56ec Convert test fixtures to async (#142052) 2025-04-02 14:05:07 +02:00
Erik Montnemery
93162f6b65 Mark Event and HassJob with @final (#142055) 2025-04-02 14:04:48 +02:00
Joost Lekkerkerker
93ea88f3de Improve SmartThings sensor deprecation (#142070)
* Improve SmartThings sensor deprecation

* Improve SmartThings sensor deprecation

* Improve SmartThings sensor deprecation
2025-04-02 13:56:23 +02:00
Joost Lekkerkerker
ca48b07858 Add Eve brand (#142067) 2025-04-02 13:54:58 +02:00
Erik Montnemery
795e01512a Correct TodoItem docstrings (#142066) 2025-04-02 13:49:12 +02:00
Petro31
36857b4b20 Fix weather templates using new style configuration (#136677) 2025-04-02 12:38:48 +02:00
Robert Resch
8432b6a790 Bump deebot-client to 12.5.0 (#142046) 2025-04-02 11:48:27 +02:00
Erik Montnemery
e02a6f2f19 Convert alexa test fixtures to async (#142054) 2025-04-02 11:00:13 +02:00
J. Nick Koston
6b45b0f522 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 10:37:27 +02:00
dependabot[bot]
c35ec1f12b Bump actions/dependency-review-action from 4.5.0 to 4.6.0 (#142042)
Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4.5.0 to 4.6.0.
- [Release notes](https://github.com/actions/dependency-review-action/releases)
- [Commits](https://github.com/actions/dependency-review-action/compare/v4.5.0...v4.6.0)

---
updated-dependencies:
- dependency-name: actions/dependency-review-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 09:19:06 +02:00
J. Nick Koston
bb7e1d4723 Reduce overhead to run headers middleware (#142032)
Instead of having to itererate a dict, update
the headers multidict using a pre-build CIMultiDict
which has an internal fast path
2025-04-02 09:09:39 +02:00
Tomek Wasilczyk
2305cb0131 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 09:07:36 +02:00
TheJulianJES
253293c986 Bump ZHA to 0.0.55 (#142031) 2025-04-02 07:45:17 +02:00
J. Nick Koston
1040fe50ec Bump aiohttp to 3.11.16 (#142034)
changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.15...v3.11.16
2025-04-02 07:43:43 +02:00
puddly
6a012498a5 Fix entity names for HA hardware firmware update entities (#142029)
* Fix entity names for HA hardware firmware update entities

* Fix unit tests
2025-04-01 18:43:01 -04:00
puddly
74c2060c49 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-01 21:39:25 +01:00
Jan Bouwhuis
177fff3ff0 Add type hint on inherrited attribute _message_callback for MQTT mixin classes (#142011) 2025-04-01 20:28:11 +02:00
G Johansson
e7fadcda7b Fix train to for multiple stations in Trafikverket Train (#142016) 2025-04-01 20:27:34 +02:00
puddly
91c53e9c52 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 20:27:06 +02:00
Norbert Rittel
bd1c66984f Sentence-case "Heat pump" / "High demand" states in water_heater (#142012) 2025-04-01 19:23:03 +01:00
Abílio Costa
704777444c Refactor Whirlpool sensor platform (#141958)
* Refactor Whirlpool sensor platform

* Rename sensor classes

* Remove unused logging

* Split washer dryer translation keys to use icon translations

* Address review comments

* Remove entity name; fix sentence casing
2025-04-01 20:02:24 +02:00
Mikko Koo
c28a6a867d 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 19:45:23 +02:00
Joost Lekkerkerker
4bfc96c02b Improve SmartThings deprecation (#141939)
* Improve SmartThings deprecation

* Improve SmartThings deprecation
2025-04-01 19:36:14 +02:00
Jan Bouwhuis
faac51d219 Improve error handling and logging on MQTT update entity state updates when template rederings fails (#141960) 2025-04-01 19:22:32 +02:00
Joost Lekkerkerker
d9cd62bf65 Add LG ThinQ event bus listener to lifecycle hooks (#142006) 2025-04-01 19:20:31 +02:00
Bram Kragten
6007629293 Update frontend to 20250401.0 (#142010) 2025-04-01 19:19:53 +02:00
Markus Adrario
426e9846d9 Add Homee climate platform (#141616)
* Add climate platform

* Add climate tests

* Add service tests

* Add snapshot test

* Code optimazitions 1

* Add test for current preset mode.

* code optimization 2

* code optimization 3

* small tweaks

* another small tweak

* Last minute changes

* Update tests/components/homee/test_climate.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* fix review comments

* typo

* more review fixes.

* maybe final review fixes.

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-04-01 18:08:36 +02:00
Norbert Rittel
935db1308f Add common states for "Low", "Medium" and "High" (#141999) 2025-04-01 18:07:19 +02:00
aaronburt
597540b611 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 17:17:34 +02:00
LG-ThinQ-Integration
e0b030c892 Add select for dehumidifier's mode control (#140572)
* Add select for dehumidifier

* Add device_class POWER

* Delete not related to select

* Update homeassistant/components/lg_thinq/strings.json

---------

Co-authored-by: yunseon.park <yunseon.park@lge.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-04-01 17:14:39 +02:00
tmenguy
da9b3dc68b Better throttling handling for the Renault API (#141667)
* Added some better throttling handling for the Renault API, it fixes #106777 HA ticket

* Added some better throttling handling for the Renault API, it fixes #106777 HA ticket, test fixing

* Update homeassistant/components/renault/coordinator.py

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

* Update homeassistant/components/renault/coordinator.py

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

* Update homeassistant/components/renault/coordinator.py

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

* Update homeassistant/components/renault/coordinator.py

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

* Update homeassistant/components/renault/coordinator.py

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

* Update homeassistant/components/renault/coordinator.py

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

* Update homeassistant/components/renault/coordinator.py

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

* Update homeassistant/components/renault/renault_hub.py

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

* bigger testsuite for  #106777 HA ticket

* bigger testsuite for  #106777 HA ticket

* bigger testsuite for  #106777 HA ticket

* bigger testsuite for  #106777 HA ticket

* Adjust tests

* Update homeassistant/components/renault/coordinator.py

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

* Update homeassistant/components/renault/renault_hub.py

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

* Update homeassistant/components/renault/renault_hub.py

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

* Update tests/components/renault/test_sensor.py

* Update tests/components/renault/test_sensor.py

* Update tests/components/renault/test_sensor.py

* requested changes  #106777 HA ticket

* Use unkown

* Fix test

* Fix test again

* Reduce and fix

* Use assumed_state

* requested changes  #106777 HA ticket

---------

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-04-01 17:14:21 +02:00
Norbert Rittel
23b79b2f39 Capitalize app name in deluge description string (#142003)
This should help fix / prevent some wrong translations like "impostazioni di diluvio" in Italian.
2025-04-01 16:39:22 +02:00
Erik Montnemery
b9a0d553ab 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 16:29:18 +02:00
Artur Pragacz
c4f0d9d2fa Always set up after dependencies if they are scheduled to be loaded (#141593)
* Always setup after dependencies

* Add comment
2025-04-01 16:28:29 +02:00
Erik Montnemery
78338f161f Add base class for onboarding views (#141970) 2025-04-01 16:13:18 +02:00
Simone Chemelli
aaafdee56f Remove un-necessary wait for background tasks in Comelit tests (#142000) 2025-04-01 16:05:46 +02:00
Dan Raper
7068986c14 Bump Ohme to platinum (#141762)
* Bump version of ohmepy and fix types

* Add strict typing to ohme

* Inject websession for ohme

* CI/code formatting fixes for ohme

* Update mypy.ini for ohme

* Fix typing in services for ohme

* Bump ohme quality in manifest
2025-04-01 15:48:45 +02:00
Louis Christ
32ee31b8c7 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 14:41:24 +02:00
Simone Chemelli
50c12d4487 Move Vodafone Station to platinum quality scale (#141406) 2025-04-01 14:39:44 +02:00
Erik Montnemery
2427b77363 Use send_json_auto_id in websocket_api tests (#141994) 2025-04-01 14:31:49 +02:00
Erik Montnemery
fa9613a879 Unconditionally import turbojpeg from camera (#141995) 2025-04-01 14:24:15 +02:00
Erik Montnemery
145e02769c Remove redundant type hint from core_config.py (#141989) 2025-04-01 13:54:24 +03:00
Norbert Rittel
c151696357 Fix spelling in Reolink user-facing strings (#141971)
Fix spelling in `reolink` user-facing string

- replace three occurrences of "a" with proper "an"
- replace "infra red" with "infrared"
2025-04-01 11:38:48 +02:00
Paulus Schoutsen
cbcd1929dd Move Z-Wave JS smoke, CO, CO2, Heat, Water problem entities to diagnostic (#129922)
* Move Z-Wave JS smoke, CO, CO2, Heat, Water problem entities to diagnostic

* Update link + states

* Specify problem class explicitly instead of catch-all

* Heat alarm test is not a problem

* Also split out smoke alarm

* Document mapping rule

* add tests

* format

* update test

* review comments

* remove idle state from doc as it is ignored

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-04-01 11:37:59 +02:00
Norbert Rittel
7a9836064d Replace "A entity" with "An entity" in modbus (#141973)
* Replace "A entity" with "An entity" in `modbus`

* Fix wrong commas
2025-04-01 11:14:41 +02:00
epenet
3155c1cd4f Add tests for renault QuotaLimitException (#141985) 2025-04-01 11:01:13 +02:00
Norbert Rittel
28c38e92d4 Fix typo "certificartes" in fully_kiosk (#141979) 2025-04-01 09:28:41 +02:00
Norbert Rittel
9c3b9eee2a Replace "a entity" with "an entity" in isy994 user strings (#141972) 2025-04-01 09:52:31 +03:00
J. Nick Koston
def50b255d 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 08:31:25 +02:00
Steven Stallion
aa7694e81c Bump sensorpush-api to 2.1.2 (#141965) 2025-04-01 07:29:09 +02:00
Manu
a722912e05 Add translations for flash options in light.turn_on action (#141950) 2025-04-01 05:43:24 +02:00
Norbert Rittel
a09213bce8 Replace "Start" and "Disable" with common actions in hassio (#141953) 2025-03-31 23:23:25 +02:00
J. Nick Koston
0abaaa0a06 Bump pydantic to 2.11.1 (#141951) 2025-03-31 23:23:02 +02:00
Ben Jones
363b88407c 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-03-31 23:16:22 +02:00
Norbert Rittel
4a4458ec5b Replace "Open" with common state in comelit (#141949) 2025-03-31 22:02:22 +02:00
Steven Looman
b3379e1921 Bump async-upnp-client to 0.44.0 (#141946) 2025-03-31 21:35:21 +02:00
Bram Kragten
09e5fbb258 Update frontend to 20250331.0 (#141943) 2025-03-31 21:23:48 +02:00
puddly
b758dc202f 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-03-31 09:10:24 -10:00
Jan-Philipp Benecke
c5f75bc135 Import function instead of relying on hass.component in watergate (#141945) 2025-03-31 21:10:14 +02:00
Michael
a904df5bc2 Add common module to ProxymoxVE integration (#141941)
add common module
2025-03-31 21:03:13 +02:00
Abílio Costa
1978e94aaa Fix Whirlpool sensor icon definition (#141937) 2025-03-31 19:32:24 +01:00
Michael Hansen
28dbf6e3dc 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 20:29:07 +02:00
Jan-Philipp Benecke
ef989160af Bump aiowebdav2 to 0.4.5 (#141934) 2025-03-31 19:08:21 +02:00
Erik Montnemery
4071eb76c7 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:33:45 +02:00
J. Nick Koston
ac723161c1 Bump grpcio to 1.71.0 (#141881) 2025-03-31 06:16:33 -10:00
elmurato
94884d33db Add button platform to Pterodactyl (#141910)
* Add button platform to Pterodactyl

* Fix parameter order of send_power_action, remove _attr_has_entity_name from button

* Rename PterodactylCommands to PterodactylCommand
2025-03-31 17:53:08 +02:00
Norbert Rittel
64994277b1 Fix spelling of "QR code" and improve grammar in tuya (#141929)
* Fix spelling of "QR code" in `tuya`

Remove the wrong hyphen.

* Add "the" to the sentence to improve the grammar
2025-03-31 17:23:14 +03:00
Josef Zweck
8abf822d92 Add None check to azure_storage (#141922) 2025-03-31 15:29:17 +02:00
Erik Montnemery
6e6f10c085 Don't create persistent notification when starting discovery flow (#141546)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-03-31 14:42:58 +02:00
Norbert Rittel
1c0768dd78 Replace "Disconnected" with common string in teslemetry (#141914)
Replaced "Disconnected" with common string in `teslemetry`
2025-03-31 14:42:07 +02:00
Paulus Schoutsen
c888502671 Add quality scale summary generator (#141780)
* Add quality scale summary generator

* Remove executable bit

* Split out virtual
2025-03-31 14:41:13 +02:00
Michael
58af3545f4 Correct further sensor categorizations in AVM Fritz!Box tools (#141911)
mark margin and attenuation as diagnostic and disable them by default
2025-03-31 13:18:44 +02:00
Norbert Rittel
d669dd45cf Use common state for "Paused" and "Unplugged" / "Plugged in" from binary sensor (#141908)
Use common state for "Paused" and "Unplugged" / "Plugged" from `binary sensor`
2025-03-31 13:18:12 +02:00
Norbert Rittel
05a5b8cdf0 Replace "Connected" and "Disconnected" with common states (#141912) 2025-03-31 13:17:46 +02:00
Norbert Rittel
33b6d0a45f Replace "Connected" and "Disconnected" with common states (#141913) 2025-03-31 13:13:48 +02:00
Joost Lekkerkerker
fba11d8016 Don't create SmartThings entities for disabled components (#141909) 2025-03-31 13:36:46 +03:00
Norbert Rittel
314834b4eb Use more common state strings in lektrico (#141906) 2025-03-31 13:36:31 +03:00
Abílio Costa
46a8325556 Simplify Energy cost sensor update method (#138961) 2025-03-31 11:32:30 +01:00
Erik Montnemery
86622cd29d Remove unnecessary imports of http integration (#141899)
* Remove unnecessary imports of http integration

* Check reason for test failures

* Revert "Check reason for test failures"

This reverts commit 5ccf356ab0.

* Update tests
2025-03-31 11:30:20 +01:00
Joost Lekkerkerker
c91a1d0fce Fix SmartThings being able to understand incomplete DRLC (#141907) 2025-03-31 12:20:06 +02:00
Dan Raper
778a2891ce Bump ohmepy to 1.5.1 (#141879)
* Bump ohmepy to 1.5.1

* Fix types for ohmepy version change
2025-03-31 11:44:01 +02:00
Retha Runolfsson
560c719b0f 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 11:42:31 +02:00
Franck Nijhof
d5ab86edbf 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 11:41:52 +02:00
Thomas55555
6aeb7f36f6 Handle 403 error in remote calendar (#141839)
* Handle 403 error in remote calendar

* tests
2025-03-31 11:40:14 +02:00
Erik Montnemery
f6308368b0 Test behavior of statistic_during_period when circular mean is undefined (#141554)
* Test behavior of statistic_during_period when circular mean is undefined

* Improve comment
2025-03-31 10:43:57 +02:00
pglab-electronics
c0e8f14745 Update support to external library pypglab to version 0.0.5 (#141876)
update support to external library pypglab to version 0.0.5
2025-03-31 10:25:48 +02:00
elmurato
0488012c77 Add sensor platform to Pterodactyl (#141428)
* Add sensor platform

* Correct CPU Limit state attribute translation

* Remove calculated util entitites, add usage and limit entities

* Use suggested_unit_of_measurement instead of converters

* Start only first word of sensor names in upper case, improve suggested units of sensors

* Simplify update of native_value, set uptime as timestamp

* Add paranthesis around multi-line lambda
2025-03-31 10:23:40 +02:00
J. Nick Koston
f247183e11 Bump SQLAlchemy to 2.0.40 (#141898)
changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.40
2025-03-31 10:11:13 +02:00
Norbert Rittel
c662b94d06 Replace "Away" in climate with common state string, matching "Home" (#141897)
* Replace "Away" in `climate` with common state string

Also reordered the states a bit to group the two presence-based options at the top and order the rest alphabetically.

* Prettier
2025-03-31 09:56:10 +02:00
Norbert Rittel
ee4bf165b5 Use common state for "Away" in nobo_hub (#141895) 2025-03-31 08:45:19 +02:00
Norbert Rittel
92ac396d19 Use common state for "Away" in honeywell (#141894) 2025-03-31 08:44:42 +02:00
Norbert Rittel
03366038ce Define "Away" state in plugwise using common string (#141875) 2025-03-31 07:35:03 +02:00
Noah Husby
0b91aa9202 Bump aiorussound to 4.5.0 (#141892) 2025-03-31 07:32:14 +02:00
Norbert Rittel
ffc4fa1c2a Replace "Away" in humidifier with common string (#141872) 2025-03-31 07:29:17 +02:00
Norbert Rittel
15e03957a9 Replace "Away" in generic_thermostat with common string (#141880) 2025-03-31 07:25:19 +02:00
Marc Mueller
0be881bca6 Fix test RuntimeWarnings for homeassistant_hardware (#141884) 2025-03-31 07:24:02 +02:00
Paulus Schoutsen
e88b321741 Ensure user always has first turn for Google Gen AI (#141893) 2025-03-30 23:31:45 -04:00
Allen Porter
0c4cb27fe9 Add OAuth support for Model Context Protocol (mcp) integration (#141874)
* Add authentication support for Model Context Protocol (mcp) integration

* Update homeassistant/components/mcp/application_credentials.py

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* Handle MCP servers with ports

---------

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2025-03-30 23:14:52 -04:00
J. Nick Koston
1639163c2e Handle encryption being disabled on an ESPHome device (#141887)
fixes #121442
2025-03-30 21:25:24 -04:00
J. Nick Koston
f043404cd9 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-30 21:23:54 -04:00
J. Nick Koston
018651ff1d Improve handling of empty iterable in async_add_entities (#141889)
* Improve handling of empty iterable in async_add_entities

We had two checks here because we were doing an empty
iterable check. If its a list we can check it directly
but if its not we need to convert it to a list to know
if its empty.

* tweaks

* tasks never used
2025-03-30 21:22:47 -04:00
J. Nick Koston
704d7a037c Bump aioesphomeapi to 29.8.0 (#141888)
changelog: https://github.com/esphome/aioesphomeapi/compare/v29.7.0...v29.8.0
2025-03-30 21:14:17 -04:00
Marc Mueller
7336178e03 Fix test RuntimeWarnings for hassio (#141883) 2025-03-30 12:00:48 -10:00
tdfountain
1c16fb8e42 Set and check unique id of config in NUT (#141783)
* Set and check unique id in config

* Update homeassistant/components/nut/config_flow.py

Set unique ID and abort only if value is defined

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

* Add duplicate ID test case for multiple devices

* Add unique ID check to config flow step for UPS

* Update homeassistant/components/nut/__init__.py

Fix to only set config_entries unique ID if not None

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

* Remove duplicate config flow call

---------

Co-authored-by: J. Nick Koston <nick+github@koston.org>
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-03-30 11:41:56 -10:00
tdfountain
3ab2cd3fb7 Set device connection MAC address for networked devices in NUT (#141856)
* Set device connection MAC address for networked devices

* Change variable name for consistency
2025-03-30 11:21:11 -10:00
Norbert Rittel
5057343b6a Replace "Home" and "Away" in vallox with common strings (#141870) 2025-03-30 22:49:52 +02:00
Norbert Rittel
6c3e85fd5e Replace "Home" and "Away" in reolink with common strings (#141869) 2025-03-30 22:44:48 +02:00
Norbert Rittel
f046456445 Replace "Home" and "Away" in opentherm_gw with common strings (#141867) 2025-03-30 22:36:46 +02:00
tdfountain
e81a08916a Remove scan interval option from NUT (#141845)
Remove scan interval option and test case, migrate config and add migration test case
2025-03-30 22:34:45 +02:00
John Karabudak
85d2e3d006 Fix LLM to speed up prefill (#141156)
* fix: two minor LLM changes to speed up prefill

- moved the current date/time to the end of the prompt
- started sorting all entities by last_changed

* addressed PR comments

* fixed tests

* reduced scope of try/catch in LLM prompt

* addressed more PR comments

* fixed Anthropic test

* addressed another PR comment

* fixed remainder of tests
2025-03-30 13:30:40 -07:00
Norbert Rittel
936b0b32ed Replace "Home" and "Away" in drop_connect with common strings (#141864) 2025-03-30 22:30:08 +02:00
J. Nick Koston
0d511c697c Improve performance of as_compressed_state (#141800)
We have to build all of these at startup.

Its a lot faster to compare floats instead
of datetime objects. Since we already have to
fetch last_changed_timestamp, use it to compare
with last_updated_timestamp since we already know
we will have last_updated_timestamp
2025-03-30 22:20:24 +02:00
Norbert Rittel
5bfe034b4d Replace "Country" with common and pollutant labels with sensor strings (#141863)
* Replace "Country" with common and pollutant labels with `sensor` strings

* Fix copy & paste error for "ozone"
2025-03-30 22:17:51 +02:00
J. Nick Koston
cf786b3b04 Bump google_cloud deps (#141861)
speech: https://github.com/googleapis/google-cloud-python/compare/google-cloud-speech-v2.27.0...google-cloud-speech-v2.31.1
texttospeech: https://github.com/googleapis/google-cloud-python/compare/google-cloud-texttospeech-v2.17.2...google-cloud-texttospeech-v2.25.1
2025-03-30 22:15:19 +02:00
Joost Lekkerkerker
0f9f090db2 Bump pySmartThings to 3.0.1 (#141722) 2025-03-30 21:34:49 +02:00
J. Nick Koston
302eea7418 Bump PyISY to 3.4.0 (#141851)
* Bump PyISY to 3.3.0

changelog: https://github.com/automicus/PyISY/compare/v3.2.0...v3.3.0

* Apply suggestions from code review

---------

Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-03-30 22:29:51 +03:00
J. Nick Koston
b5e1f7e03e Cleanup some typing in isy994 (#141859)
Now that pyisy is mostly typed there were some obvious
issues. We are still a long way away from being able
to add py.typed to pyisy, but we can now see some
obvious things in an IDE
2025-03-30 09:18:30 -10:00
Norbert Rittel
02397a8d2d Replace "Off" state in selectors of home_connect with common state (#141857)
* Replace "Off" state in selectors of `home_connect` with common state

* Replace internal with common references
2025-03-30 21:03:46 +02:00
Norbert Rittel
ea9437eab2 Use common state for "Off" in climate selector (#141850)
* Use common states for "Away" and "Off" in `climate`

* Revert common state for "Away"

Four other integrations are referencing this instead of the common state. Needs to be addressed first.
2025-03-30 21:02:54 +02:00
Norbert Rittel
aaea30bee0 Replace "Off" in selector of media_player with common state (#141853) 2025-03-30 21:01:03 +02:00
Joost Lekkerkerker
9c869fa701 Add a coordinator to Point (#126775)
* Add a coordinator to Point

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix
2025-03-30 20:58:40 +02:00
Eli Sand
5106548f2c Fix generic_thermostat so it doesn't turn on when current temp is within target temp range (#138209)
* Don't turn on thermostat if temp is equal to target temp.

* Update strings to reflect logic change.

* Fix logic and add zero tolerance tests.

* Include tests for cool mode

* Removed unnecessary async_block_till_done calls
2025-03-30 19:43:13 +01:00
Franck Nijhof
506d485c0d Ensure EcoNet operation modes are unique (#141689) 2025-03-30 20:31:08 +02:00
Bouwe Westerdijk
da190ec96f Bump plugwise to v1.7.3 (#141843) 2025-03-30 20:24:13 +02:00
Franck Nijhof
9567929484 Update pvo to v2.2.1 (#141847) 2025-03-30 21:12:42 +03:00
Norbert Rittel
dc16494332 Replace "Disabled" with common state in schlage, fix sentence-case (#141849)
Replace "Disabled" with common state in `lamarzocco`, fix sentence-case

- replace "Disabled" with with common state reference
- fix sentence-casing of "Auto-lock"
2025-03-30 21:12:15 +03:00
Norbert Rittel
933f422588 Replace "Disabled" with common state in lamarzocco (#141848) 2025-03-30 20:00:18 +02:00
Michael
663d0691a7 Move setup messages from info to debug level (#141834)
move info to debug level
2025-03-30 19:49:41 +02:00
310 changed files with 11489 additions and 4167 deletions

View File

@@ -653,7 +653,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Dependency review
uses: actions/dependency-review-action@v4.5.0
uses: actions/dependency-review-action@v4.6.0
with:
license-check: false # We use our own license audit checks

View File

@@ -364,6 +364,7 @@ homeassistant.components.notify.*
homeassistant.components.notion.*
homeassistant.components.number.*
homeassistant.components.nut.*
homeassistant.components.ohme.*
homeassistant.components.onboarding.*
homeassistant.components.oncue.*
homeassistant.components.onedrive.*

15
Dockerfile generated
View File

@@ -14,21 +14,8 @@ ARG QEMU_CPU
# Home Assistant S6-Overlay
COPY rootfs /
# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary
RUN \
case "${BUILD_ARCH}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
COPY --from=ghcr.io/alexxit/go2rtc:1.9.9 /usr/local/bin/go2rtc /bin/go2rtc
# Install uv
RUN pip3 install uv==0.6.10

View File

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

View File

@@ -16,8 +16,8 @@
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"city": "City",
"country": "Country",
"state": "State"
"state": "State",
"country": "[%key:common::config_flow::data::country%]"
}
},
"reauth_confirm": {
@@ -56,12 +56,12 @@
"sensor": {
"pollutant_label": {
"state": {
"co": "Carbon monoxide",
"n2": "Nitrogen dioxide",
"o3": "Ozone",
"p1": "PM10",
"p2": "PM2.5",
"s2": "Sulfur dioxide"
"co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"n2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"o3": "[%key:component::sensor::entity_component::ozone::name%]",
"p1": "[%key:component::sensor::entity_component::pm10::name%]",
"p2": "[%key:component::sensor::entity_component::pm25::name%]",
"s2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
}
},
"pollutant_level": {

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

@@ -15,6 +15,11 @@ announce:
required: false
selector:
text:
preannounce:
required: false
default: true
selector:
boolean:
preannounce_media_id:
required: false
selector:
@@ -40,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

@@ -2,17 +2,10 @@
from __future__ import annotations
from contextlib import suppress
import logging
from typing import TYPE_CHECKING, Literal, cast
with suppress(Exception):
# TurboJPEG imports numpy which may or may not work so
# we have to guard the import here. We still want
# to import it at top level so it gets loaded
# in the import executor and not in the event loop.
from turbojpeg import TurboJPEG
from turbojpeg import TurboJPEG
if TYPE_CHECKING:
from . import Image

View File

@@ -98,13 +98,13 @@
"name": "Preset",
"state": {
"none": "None",
"eco": "Eco",
"away": "Away",
"home": "[%key:common::state::home%]",
"away": "[%key:common::state::not_home%]",
"activity": "Activity",
"boost": "Boost",
"comfort": "Comfort",
"home": "[%key:common::state::home%]",
"sleep": "Sleep",
"activity": "Activity"
"eco": "Eco",
"sleep": "Sleep"
}
},
"preset_modes": {
@@ -257,7 +257,7 @@
"selector": {
"hvac_mode": {
"options": {
"off": "Off",
"off": "[%key:common::state::off%]",
"auto": "Auto",
"cool": "Cool",
"dry": "Dry",

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

@@ -42,9 +42,9 @@
"sensor": {
"zone_status": {
"state": {
"open": "[%key:common::state::open%]",
"alarm": "Alarm",
"armed": "Armed",
"open": "Open",
"excluded": "Excluded",
"faulty": "Faulty",
"inhibited": "Inhibited",

View File

@@ -354,6 +354,35 @@ class ChatLog:
if self.delta_listener:
self.delta_listener(self, asdict(tool_result))
async def _async_expand_prompt_template(
self,
llm_context: llm.LLMContext,
prompt: str,
language: str,
user_name: str | None = None,
) -> str:
try:
return template.Template(prompt, self.hass).async_render(
{
"ha_name": self.hass.config.location_name,
"user_name": user_name,
"llm_context": llm_context,
},
parse_result=False,
)
except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Sorry, I had a problem with my template",
)
raise ConverseError(
"Error rendering prompt",
conversation_id=self.conversation_id,
response=intent_response,
) from err
async def async_update_llm_data(
self,
conversing_domain: str,
@@ -409,38 +438,28 @@ class ChatLog:
):
user_name = user.name
try:
prompt_parts = [
template.Template(
llm.BASE_PROMPT
+ (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
self.hass,
).async_render(
{
"ha_name": self.hass.config.location_name,
"user_name": user_name,
"llm_context": llm_context,
},
parse_result=False,
)
]
except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Sorry, I had a problem with my template",
prompt_parts = []
prompt_parts.append(
await self._async_expand_prompt_template(
llm_context,
(user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
user_input.language,
user_name,
)
raise ConverseError(
"Error rendering prompt",
conversation_id=self.conversation_id,
response=intent_response,
) from err
)
if llm_api:
prompt_parts.append(llm_api.api_prompt)
prompt_parts.append(
await self._async_expand_prompt_template(
llm_context,
llm.BASE_PROMPT,
user_input.language,
user_name,
)
)
if extra_system_prompt := (
# Take new system prompt if one was given
user_input.extra_system_prompt or self.extra_system_prompt

View File

@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
"description": "To be able to use this integration, you have to enable the following option in deluge settings: Daemon > Allow remote controls",
"description": "To be able to use this integration, you have to enable the following option in Deluge settings: Daemon > Allow remote controls",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",

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

@@ -38,8 +38,8 @@
"protect_mode": {
"name": "Protect mode",
"state": {
"away": "Away",
"home": "Home",
"away": "[%key:common::state::not_home%]",
"home": "[%key:common::state::home%]",
"schedule": "Schedule"
}
}

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

@@ -25,6 +25,7 @@ from homeassistant.core import (
split_entity_id,
valid_entity_id,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
@@ -122,6 +123,10 @@ SOURCE_ADAPTERS: Final = (
)
class EntityNotFoundError(HomeAssistantError):
"""When a referenced entity was not found."""
class SensorManager:
"""Class to handle creation/removal of sensor data."""
@@ -311,43 +316,25 @@ class EnergyCostSensor(SensorEntity):
except ValueError:
return
# Determine energy price
if self._config["entity_energy_price"] is not None:
energy_price_state = self.hass.states.get(
self._config["entity_energy_price"]
try:
energy_price, energy_price_unit = self._get_energy_price(
valid_units, default_price_unit
)
if energy_price_state is None:
return
try:
energy_price = float(energy_price_state.state)
except ValueError:
if self._last_energy_sensor_state is None:
# Initialize as it's the first time all required entities except
# price are in place. This means that the cost will update the first
# time the energy is updated after the price entity is in place.
self._reset(energy_state)
return
energy_price_unit: str | None = energy_price_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT, ""
).partition("/")[2]
# For backwards compatibility we don't validate the unit of the price
# If it is not valid, we assume it's our default price unit.
if energy_price_unit not in valid_units:
energy_price_unit = default_price_unit
else:
energy_price = cast(float, self._config["number_energy_price"])
energy_price_unit = default_price_unit
except EntityNotFoundError:
return
except ValueError:
energy_price = None
if self._last_energy_sensor_state is None:
# Initialize as it's the first time all required entities are in place.
# Initialize as it's the first time all required entities are in place or
# only the price is missing. In the later case, cost will update the first
# time the energy is updated after the price entity is in place.
self._reset(energy_state)
return
if energy_price is None:
return
energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if energy_unit is None or energy_unit not in valid_units:
@@ -383,20 +370,9 @@ class EnergyCostSensor(SensorEntity):
old_energy_value = float(self._last_energy_sensor_state.state)
cur_value = cast(float, self._attr_native_value)
if energy_price_unit is None:
converted_energy_price = energy_price
else:
converter: Callable[[float, str, str], float]
if energy_unit in VALID_ENERGY_UNITS:
converter = unit_conversion.EnergyConverter.convert
else:
converter = unit_conversion.VolumeConverter.convert
converted_energy_price = converter(
energy_price,
energy_unit,
energy_price_unit,
)
converted_energy_price = self._convert_energy_price(
energy_price, energy_price_unit, energy_unit
)
self._attr_native_value = (
cur_value + (energy - old_energy_value) * converted_energy_price
@@ -404,6 +380,52 @@ class EnergyCostSensor(SensorEntity):
self._last_energy_sensor_state = energy_state
def _get_energy_price(
self, valid_units: set[str], default_unit: str | None
) -> tuple[float, str | None]:
"""Get the energy price.
Raises:
EntityNotFoundError: When the energy price entity is not found.
ValueError: When the entity state is not a valid float.
"""
if self._config["entity_energy_price"] is None:
return cast(float, self._config["number_energy_price"]), default_unit
energy_price_state = self.hass.states.get(self._config["entity_energy_price"])
if energy_price_state is None:
raise EntityNotFoundError
energy_price = float(energy_price_state.state)
energy_price_unit: str | None = energy_price_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT, ""
).partition("/")[2]
# For backwards compatibility we don't validate the unit of the price
# If it is not valid, we assume it's our default price unit.
if energy_price_unit not in valid_units:
energy_price_unit = default_unit
return energy_price, energy_price_unit
def _convert_energy_price(
self, energy_price: float, energy_price_unit: str | None, energy_unit: str
) -> float:
"""Convert the energy price to the correct unit."""
if energy_price_unit is None:
return energy_price
converter: Callable[[float, str, str], float]
if energy_unit in VALID_ENERGY_UNITS:
converter = unit_conversion.EnergyConverter.convert
else:
converter = unit_conversion.VolumeConverter.convert
return converter(energy_price, energy_unit, energy_price_unit)
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key])

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

@@ -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

@@ -238,6 +238,8 @@ SENSOR_TYPES: tuple[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

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

View File

@@ -2,7 +2,7 @@
"common": {
"data_description_password": "The Remote Admin password from the Fully Kiosk Browser app settings.",
"data_description_ssl": "Is the Fully Kiosk app configured to require SSL for the connection?",
"data_description_verify_ssl": "Should SSL certificartes be verified? This should be off for self-signed certificates."
"data_description_verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates."
},
"config": {
"step": {

View File

@@ -539,10 +539,14 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
return
assert self._cur_temp is not None and self._target_temp is not None
too_cold = self._target_temp >= self._cur_temp + self._cold_tolerance
too_hot = self._cur_temp >= self._target_temp + self._hot_tolerance
min_temp = self._target_temp - self._cold_tolerance
max_temp = self._target_temp + self._hot_tolerance
if self._is_device_active:
if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot):
if (self.ac_mode and self._cur_temp <= min_temp) or (
not self.ac_mode and self._cur_temp >= max_temp
):
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
await self._async_heater_turn_off()
elif time is not None:
@@ -552,7 +556,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
self.heater_entity_id,
)
await self._async_heater_turn_on()
elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold):
elif (self.ac_mode and self._cur_temp > max_temp) or (
not self.ac_mode and self._cur_temp < min_temp
):
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
await self._async_heater_turn_on()
elif time is not None:

View File

@@ -21,17 +21,17 @@
"heater": "Switch entity used to cool or heat depending on A/C mode.",
"target_sensor": "Temperature sensor that reflects the current temperature.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.",
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.",
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5."
}
},
"presets": {
"title": "Temperature presets",
"data": {
"away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
"home_temp": "[%key:common::state::home%]",
"away_temp": "[%key:common::state::not_home%]",
"comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
"eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
"home_temp": "[%key:common::state::home%]",
"sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]",
"activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]"
}
@@ -63,10 +63,10 @@
"presets": {
"title": "[%key:component::generic_thermostat::config::step::presets::title%]",
"data": {
"away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
"home_temp": "[%key:common::state::home%]",
"away_temp": "[%key:common::state::not_home%]",
"comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
"eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
"home_temp": "[%key:common::state::home%]",
"sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]",
"activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]"
}

View File

@@ -8,7 +8,7 @@
"integration_type": "service",
"iot_class": "cloud_push",
"requirements": [
"google-cloud-texttospeech==2.17.2",
"google-cloud-speech==2.27.0"
"google-cloud-texttospeech==2.25.1",
"google-cloud-speech==2.31.1"
]
}

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

@@ -24,8 +24,8 @@
"fix_menu": {
"description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.",
"menu_options": {
"addon_execute_start": "Start",
"addon_disable_boot": "Disable"
"addon_execute_start": "[%key:common::action::start%]",
"addon_disable_boot": "[%key:common::action::disable%]"
}
}
},

View File

@@ -511,7 +511,7 @@
},
"spin_speed": {
"options": {
"laundry_care_washer_enum_type_spin_speed_off": "Off",
"laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m_400": "400 rpm",
"laundry_care_washer_enum_type_spin_speed_r_p_m_600": "600 rpm",
"laundry_care_washer_enum_type_spin_speed_r_p_m_700": "700 rpm",
@@ -521,7 +521,7 @@
"laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "1200 rpm",
"laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm",
"laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm",
"laundry_care_washer_enum_type_spin_speed_ul_off": "Off",
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]",
"laundry_care_washer_enum_type_spin_speed_ul_low": "Low",
"laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium",
"laundry_care_washer_enum_type_spin_speed_ul_high": "High"
@@ -529,7 +529,7 @@
},
"vario_perfect": {
"options": {
"laundry_care_common_enum_type_vario_perfect_off": "Off",
"laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]",
"laundry_care_common_enum_type_vario_perfect_eco_perfect": "Eco perfect",
"laundry_care_common_enum_type_vario_perfect_speed_perfect": "Speed perfect"
}
@@ -1494,7 +1494,7 @@
"spin_speed": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]",
"state": {
"laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]",
"laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m_400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_400%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m_600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_600%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m_700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_700%]",
@@ -1504,7 +1504,7 @@
"laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1200%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]",
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]",
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]",
"laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]",
"laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]",
"laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]"
@@ -1513,7 +1513,7 @@
"vario_perfect": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]",
"state": {
"laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]",
"laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]",
"laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]",
"laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]"
}

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,10 +194,6 @@ 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"
if (

View File

@@ -3,19 +3,81 @@
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
@@ -29,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:
@@ -64,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

@@ -168,7 +168,6 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""SkyConnect firmware update entity."""
bootloader_reset_type = None
_attr_has_entity_name = True
def __init__(
self,

View File

@@ -152,7 +152,7 @@
},
"entity": {
"update": {
"firmware": {
"radio_firmware": {
"name": "Radio firmware"
}
}

View File

@@ -44,6 +44,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
] = {
ApplicationType.EZSP: FirmwareUpdateEntityDescription(
key="radio_firmware",
translation_key="radio_firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
@@ -55,6 +56,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
),
ApplicationType.SPINEL: FirmwareUpdateEntityDescription(
key="radio_firmware",
translation_key="radio_firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
@@ -65,7 +67,8 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
firmware_name="OpenThread RCP",
),
ApplicationType.CPC: FirmwareUpdateEntityDescription(
key="firmware",
key="radio_firmware",
translation_key="radio_firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
@@ -76,7 +79,8 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
firmware_name="Multiprotocol",
),
ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription(
key="firmware",
key="radio_firmware",
translation_key="radio_firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
@@ -88,6 +92,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
),
None: FirmwareUpdateEntityDescription(
key="radio_firmware",
translation_key="radio_firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
@@ -168,7 +173,6 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Yellow firmware update entity."""
bootloader_reset_type = "yellow" # Triggers a GPIO reset
_attr_has_entity_name = True
def __init__(
self,

View File

@@ -17,6 +17,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.COVER,
Platform.LIGHT,
Platform.LOCK,

View File

@@ -0,0 +1,200 @@
"""The Homee climate platform."""
from typing import Any
from pyHomee.const import AttributeType, NodeProfile
from pyHomee.model import HomeeNode
from homeassistant.components.climate import (
ATTR_TEMPERATURE,
PRESET_BOOST,
PRESET_ECO,
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .const import CLIMATE_PROFILES, DOMAIN, HOMEE_UNIT_TO_HA_UNIT, PRESET_MANUAL
from .entity import HomeeNodeEntity
PARALLEL_UPDATES = 0
ROOM_THERMOSTATS = {
NodeProfile.ROOM_THERMOSTAT,
NodeProfile.ROOM_THERMOSTAT_WITH_HUMIDITY_SENSOR,
NodeProfile.WIFI_ROOM_THERMOSTAT,
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the climate component."""
async_add_devices(
HomeeClimate(node, config_entry)
for node in config_entry.runtime_data.nodes
if node.profile in CLIMATE_PROFILES
)
class HomeeClimate(HomeeNodeEntity, ClimateEntity):
"""Representation of a Homee climate entity."""
_attr_name = None
_attr_translation_key = DOMAIN
def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
"""Initialize a Homee climate entity."""
super().__init__(node, entry)
(
self._attr_supported_features,
self._attr_hvac_modes,
self._attr_preset_modes,
) = get_climate_features(self._node)
self._target_temp = self._node.get_attribute_by_type(
AttributeType.TARGET_TEMPERATURE
)
assert self._target_temp is not None
self._attr_temperature_unit = str(HOMEE_UNIT_TO_HA_UNIT[self._target_temp.unit])
self._attr_target_temperature_step = self._target_temp.step_value
self._attr_unique_id = f"{self._attr_unique_id}-{self._target_temp.id}"
self._heating_mode = self._node.get_attribute_by_type(
AttributeType.HEATING_MODE
)
self._temperature = self._node.get_attribute_by_type(AttributeType.TEMPERATURE)
self._valve_position = self._node.get_attribute_by_type(
AttributeType.CURRENT_VALVE_POSITION
)
@property
def hvac_mode(self) -> HVACMode:
"""Return the hvac operation mode."""
if ClimateEntityFeature.TURN_OFF in self.supported_features and (
self._heating_mode is not None
):
if self._heating_mode.current_value == 0:
return HVACMode.OFF
return HVACMode.HEAT
@property
def hvac_action(self) -> HVACAction:
"""Return the hvac action."""
if self._heating_mode is not None and self._heating_mode.current_value == 0:
return HVACAction.OFF
if (
self._valve_position is not None and self._valve_position.current_value == 0
) or (
self._temperature is not None
and self._temperature.current_value >= self.target_temperature
):
return HVACAction.IDLE
return HVACAction.HEATING
@property
def preset_mode(self) -> str:
"""Return the present preset mode."""
if (
ClimateEntityFeature.PRESET_MODE in self.supported_features
and self._heating_mode is not None
and self._heating_mode.current_value > 0
):
assert self._attr_preset_modes is not None
return self._attr_preset_modes[int(self._heating_mode.current_value) - 1]
return PRESET_NONE
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self._temperature is not None:
return self._temperature.current_value
return None
@property
def target_temperature(self) -> float:
"""Return the temperature we try to reach."""
assert self._target_temp is not None
return self._target_temp.current_value
@property
def min_temp(self) -> float:
"""Return the lowest settable target temperature."""
assert self._target_temp is not None
return self._target_temp.minimum
@property
def max_temp(self) -> float:
"""Return the lowest settable target temperature."""
assert self._target_temp is not None
return self._target_temp.maximum
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
# Currently only HEAT and OFF are supported.
assert self._heating_mode is not None
await self.async_set_homee_value(
self._heating_mode, float(hvac_mode == HVACMode.HEAT)
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target preset mode."""
assert self._heating_mode is not None and self._attr_preset_modes is not None
await self.async_set_homee_value(
self._heating_mode, self._attr_preset_modes.index(preset_mode) + 1
)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
assert self._target_temp is not None
if ATTR_TEMPERATURE in kwargs:
await self.async_set_homee_value(
self._target_temp, kwargs[ATTR_TEMPERATURE]
)
async def async_turn_on(self) -> None:
"""Turn the entity on."""
assert self._heating_mode is not None
await self.async_set_homee_value(self._heating_mode, 1)
async def async_turn_off(self) -> None:
"""Turn the entity on."""
assert self._heating_mode is not None
await self.async_set_homee_value(self._heating_mode, 0)
def get_climate_features(
node: HomeeNode,
) -> tuple[ClimateEntityFeature, list[HVACMode], list[str] | None]:
"""Determine supported climate features of a node based on the available attributes."""
features = ClimateEntityFeature.TARGET_TEMPERATURE
hvac_modes = [HVACMode.HEAT]
preset_modes: list[str] = []
if (
attribute := node.get_attribute_by_type(AttributeType.HEATING_MODE)
) is not None:
features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
hvac_modes.append(HVACMode.OFF)
if attribute.maximum > 1:
# Node supports more modes than off and heating.
features |= ClimateEntityFeature.PRESET_MODE
preset_modes.extend([PRESET_ECO, PRESET_BOOST, PRESET_MANUAL])
if len(preset_modes) > 0:
preset_modes.insert(0, PRESET_NONE)
return (features, hvac_modes, preset_modes if len(preset_modes) > 0 else None)

View File

@@ -95,3 +95,6 @@ LIGHT_PROFILES = [
NodeProfile.WIFI_DIMMABLE_LIGHT,
NodeProfile.WIFI_ON_OFF_DIMMABLE_METERING_SWITCH,
]
# Climate Presets
PRESET_MANUAL = "manual"

View File

@@ -1,5 +1,16 @@
{
"entity": {
"climate": {
"homee": {
"state_attributes": {
"preset_mode": {
"state": {
"manual": "mdi:hand-back-left"
}
}
}
}
},
"sensor": {
"brightness": {
"default": "mdi:brightness-5"

View File

@@ -131,6 +131,17 @@
"name": "Ventilate"
}
},
"climate": {
"homee": {
"state_attributes": {
"preset_mode": {
"state": {
"manual": "Manual"
}
}
}
}
},
"light": {
"light_instance": {
"name": "Light {instance}"

View File

@@ -55,7 +55,7 @@
"preset_mode": {
"state": {
"hold": "Hold",
"away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
"away": "[%key:common::state::not_home%]",
"none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]"
}
}

View File

@@ -3,25 +3,34 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Final
from aiohttp import hdrs
from aiohttp.web import Application, Request, StreamResponse, middleware
from aiohttp.web_exceptions import HTTPException
from multidict import CIMultiDict, istr
from homeassistant.core import callback
REFERRER_POLICY: Final[istr] = istr("Referrer-Policy")
X_CONTENT_TYPE_OPTIONS: Final[istr] = istr("X-Content-Type-Options")
X_FRAME_OPTIONS: Final[istr] = istr("X-Frame-Options")
@callback
def setup_headers(app: Application, use_x_frame_options: bool) -> None:
"""Create headers middleware for the app."""
added_headers = {
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"Server": "", # Empty server header, to prevent aiohttp of setting one.
}
added_headers = CIMultiDict(
{
REFERRER_POLICY: "no-referrer",
X_CONTENT_TYPE_OPTIONS: "nosniff",
hdrs.SERVER: "", # Empty server header, to prevent aiohttp of setting one.
}
)
if use_x_frame_options:
added_headers["X-Frame-Options"] = "SAMEORIGIN"
added_headers[X_FRAME_OPTIONS] = "SAMEORIGIN"
@middleware
async def headers_middleware(

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

@@ -63,14 +63,14 @@
"name": "Mode",
"state": {
"normal": "Normal",
"eco": "Eco",
"away": "Away",
"home": "[%key:common::state::home%]",
"away": "[%key:common::state::not_home%]",
"auto": "Auto",
"baby": "Baby",
"boost": "Boost",
"comfort": "Comfort",
"home": "[%key:common::state::home%]",
"sleep": "Sleep",
"auto": "Auto",
"baby": "Baby"
"eco": "Eco",
"sleep": "Sleep"
}
}
}

View File

@@ -227,9 +227,9 @@ async def async_unload_entry(
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
isy_data = hass.data[DOMAIN][entry.entry_id]
isy_data: IsyData = hass.data[DOMAIN][entry.entry_id]
isy: ISY = isy_data.root
isy = isy_data.root
_LOGGER.debug("ISY Stopping Event Stream and automatic updates")
isy.websocket.stop()

View File

@@ -181,6 +181,7 @@ class ISYProgramEntity(ISYEntity):
_actions: Program
_status: Program
_node: Program
def __init__(self, name: str, status: Program, actions: Program = None) -> None:
"""Initialize the ISY program-based entity."""

View File

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

View File

@@ -21,6 +21,7 @@ from homeassistant.helpers.service import entity_service_call
from homeassistant.helpers.typing import VolDictType
from .const import _LOGGER, DOMAIN
from .models import IsyData
# Common Services for All Platforms:
SERVICE_SEND_PROGRAM_COMMAND = "send_program_command"
@@ -149,7 +150,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
isy_name = service.data.get(CONF_ISY)
for config_entry_id in hass.data[DOMAIN]:
isy_data = hass.data[DOMAIN][config_entry_id]
isy_data: IsyData = hass.data[DOMAIN][config_entry_id]
isy = isy_data.root
if isy_name and isy_name != isy.conf["name"]:
continue

View File

@@ -90,7 +90,7 @@
},
"get_zwave_parameter": {
"name": "Get Z-Wave Parameter",
"description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
"description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
"fields": {
"parameter": {
"name": "Parameter",
@@ -100,7 +100,7 @@
},
"set_zwave_parameter": {
"name": "Set Z-Wave parameter",
"description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
"description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
"fields": {
"parameter": {
"name": "[%key:component::isy994::services::get_zwave_parameter::fields::parameter::name%]",

View File

@@ -157,7 +157,7 @@ class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity):
device_info=device_info,
)
self._attr_name = description.name # Override super
self._change_handler: EventListener = None
self._change_handler: EventListener | None = None
# pylint: disable-next=hass-missing-super-call
async def async_added_to_hass(self) -> None:

View File

@@ -146,7 +146,7 @@
"prebrew_infusion_select": {
"name": "Prebrew/-infusion mode",
"state": {
"disabled": "Disabled",
"disabled": "[%key:common::state::disabled%]",
"prebrew": "Prebrew",
"prebrew_enabled": "Prebrew",
"preinfusion": "Preinfusion"

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

@@ -87,11 +87,11 @@
"state": {
"available": "Available",
"charging": "[%key:common::state::charging%]",
"connected": "Connected",
"connected": "[%key:common::state::connected%]",
"error": "Error",
"locked": "Locked",
"locked": "[%key:common::state::locked%]",
"need_auth": "Waiting for authentication",
"paused": "Paused",
"paused": "[%key:common::state::paused%]",
"paused_by_scheduler": "Paused by scheduler",
"updating_firmware": "Updating firmware"
}

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

@@ -169,6 +169,9 @@
"current_job_mode": {
"default": "mdi:format-list-bulleted"
},
"current_job_mode_dehumidifier": {
"default": "mdi:format-list-bulleted"
},
"operation_mode": {
"default": "mdi:gesture-tap-button"
},

View File

@@ -98,7 +98,13 @@ DEVICE_TYPE_SELECT_MAP: dict[DeviceType, tuple[SelectEntityDescription, ...]] =
AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],
SELECT_DESC[ThinQProperty.CURRENT_JOB_MODE],
),
DeviceType.DEHUMIDIFIER: (AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],),
DeviceType.DEHUMIDIFIER: (
AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],
SelectEntityDescription(
key=ThinQProperty.CURRENT_JOB_MODE,
translation_key="current_job_mode_dehumidifier",
),
),
DeviceType.DISH_WASHER: (
OPERATION_SELECT_DESC[ThinQProperty.DISH_WASHER_OPERATION_MODE],
),

View File

@@ -928,6 +928,17 @@
"vacation": "Vacation"
}
},
"current_job_mode_dehumidifier": {
"name": "[%key:component::lg_thinq::entity::sensor::current_job_mode::name%]",
"state": {
"air_clean": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::air_clean%]",
"clothes_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::clothes_dry%]",
"intensive_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::intensive_dry%]",
"quiet_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::quiet_humidity%]",
"rapid_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::rapid_humidity%]",
"smart_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::smart_humidity%]"
}
},
"operation_mode": {
"name": "Operation",
"state": {

View File

@@ -293,11 +293,10 @@ turn_on:
- light.LightEntityFeature.FLASH
selector:
select:
translation_key: flash
options:
- label: "Long"
value: "long"
- label: "Short"
value: "short"
- long
- short
turn_off:
target:

View File

@@ -283,6 +283,12 @@
"yellow": "Yellow",
"yellowgreen": "Yellow green"
}
},
"flash": {
"options": {
"short": "Short",
"long": "Long"
}
}
},
"services": {

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast
from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast, final
from propcache.api import cached_property
from sqlalchemy.engine.row import Row
@@ -114,6 +114,7 @@ DATA_POS: Final = 11
CONTEXT_POS: Final = 12
@final # Final to allow direct checking of the type instead of using isinstance
class EventAsRow(NamedTuple):
"""Convert an event to a row.

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

@@ -3,12 +3,15 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import cast
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from homeassistant.helpers import llm
from homeassistant.helpers import config_entry_oauth2_flow, llm
from .const import DOMAIN
from .coordinator import ModelContextProtocolCoordinator
from .application_credentials import authorization_server_context
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
from .coordinator import ModelContextProtocolCoordinator, TokenManager
from .types import ModelContextProtocolConfigEntry
__all__ = [
@@ -20,11 +23,45 @@ __all__ = [
API_PROMPT = "The following tools are available from a remote server named {name}."
async def async_get_config_entry_implementation(
hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation | None:
"""OAuth implementation for the config entry."""
if "auth_implementation" not in entry.data:
return None
with authorization_server_context(
AuthorizationServer(
authorize_url=entry.data[CONF_AUTHORIZATION_URL],
token_url=entry.data[CONF_TOKEN_URL],
)
):
return await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
async def _create_token_manager(
hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
) -> TokenManager | None:
"""Create a OAuth token manager for the config entry if the server requires authentication."""
if not (implementation := await async_get_config_entry_implementation(hass, entry)):
return None
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
async def token_manager() -> str:
await session.async_ensure_token_valid()
return cast(str, session.token[CONF_ACCESS_TOKEN])
return token_manager
async def async_setup_entry(
hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
) -> bool:
"""Set up Model Context Protocol from a config entry."""
coordinator = ModelContextProtocolCoordinator(hass, entry)
token_manager = await _create_token_manager(hass, entry)
coordinator = ModelContextProtocolCoordinator(hass, entry, token_manager)
await coordinator.async_config_entry_first_refresh()
unsub = llm.async_register_api(

View File

@@ -0,0 +1,35 @@
"""Application credentials platform for Model Context Protocol."""
from __future__ import annotations
from collections.abc import Generator
from contextlib import contextmanager
import contextvars
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
CONF_ACTIVE_AUTHORIZATION_SERVER = "active_authorization_server"
_mcp_context: contextvars.ContextVar[AuthorizationServer] = contextvars.ContextVar(
"mcp_authorization_server_context"
)
@contextmanager
def authorization_server_context(
authorization_server: AuthorizationServer,
) -> Generator[None]:
"""Context manager for setting the active authorization server."""
token = _mcp_context.set(authorization_server)
try:
yield
finally:
_mcp_context.reset(token)
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server, for the default auth implementation."""
if _mcp_context.get() is None:
raise RuntimeError("No MCP authorization server set in context")
return _mcp_context.get()

View File

@@ -2,20 +2,29 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from typing import Any, cast
import httpx
import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_URL
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import (
AbstractOAuth2FlowHandler,
async_get_implementations,
)
from .const import DOMAIN
from .coordinator import mcp_client
from . import async_get_config_entry_implementation
from .application_credentials import authorization_server_context
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
from .coordinator import TokenManager, mcp_client
_LOGGER = logging.getLogger(__name__)
@@ -25,8 +34,62 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
# OAuth server discovery endpoint for rfc8414
OAUTH_DISCOVERY_ENDPOINT = ".well-known/oauth-authorization-server"
MCP_DISCOVERY_HEADERS = {
"MCP-Protocol-Version": "2025-03-26",
}
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
async def async_discover_oauth_config(
hass: HomeAssistant, mcp_server_url: str
) -> AuthorizationServer:
"""Discover the OAuth configuration for the MCP server.
This implements the functionality in the MCP spec for discovery. If the MCP server URL
is https://api.example.com/v1/mcp, then:
- The authorization base URL is https://api.example.com
- The metadata endpoint MUST be at https://api.example.com/.well-known/oauth-authorization-server
- For servers that do not implement OAuth 2.0 Authorization Server Metadata, the client uses
default paths relative to the authorization base URL.
"""
parsed_url = URL(mcp_server_url)
discovery_endpoint = str(parsed_url.with_path(OAUTH_DISCOVERY_ENDPOINT))
try:
async with httpx.AsyncClient(headers=MCP_DISCOVERY_HEADERS) as client:
response = await client.get(discovery_endpoint)
response.raise_for_status()
except httpx.TimeoutException as error:
_LOGGER.info("Timeout connecting to MCP server: %s", error)
raise TimeoutConnectError from error
except httpx.HTTPStatusError as error:
if error.response.status_code == 404:
_LOGGER.info("Authorization Server Metadata not found, using default paths")
return AuthorizationServer(
authorize_url=str(parsed_url.with_path("/authorize")),
token_url=str(parsed_url.with_path("/token")),
)
raise CannotConnect from error
except httpx.HTTPError as error:
_LOGGER.info("Cannot discover OAuth configuration: %s", error)
raise CannotConnect from error
data = response.json()
authorize_url = data["authorization_endpoint"]
token_url = data["token_endpoint"]
if authorize_url.startswith("/"):
authorize_url = str(parsed_url.with_path(authorize_url))
if token_url.startswith("/"):
token_url = str(parsed_url.with_path(token_url))
return AuthorizationServer(
authorize_url=authorize_url,
token_url=token_url,
)
async def validate_input(
hass: HomeAssistant, data: dict[str, Any], token_manager: TokenManager | None = None
) -> dict[str, Any]:
"""Validate the user input and connect to the MCP server."""
url = data[CONF_URL]
try:
@@ -34,7 +97,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
except vol.Invalid as error:
raise InvalidUrl from error
try:
async with mcp_client(url) as session:
async with mcp_client(url, token_manager=token_manager) as session:
response = await session.initialize()
except httpx.TimeoutException as error:
_LOGGER.info("Timeout connecting to MCP server: %s", error)
@@ -56,10 +119,17 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
return {"title": response.serverInfo.name}
class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle a config flow for Model Context Protocol."""
VERSION = 1
DOMAIN = DOMAIN
logger = _LOGGER
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -76,7 +146,8 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
return self.async_abort(reason="invalid_auth")
self.data[CONF_URL] = user_input[CONF_URL]
return await self.async_step_auth_discovery()
except MissingCapabilities:
return self.async_abort(reason="missing_capabilities")
except Exception:
@@ -90,6 +161,130 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_auth_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the OAuth server discovery step.
Since this OAuth server requires authentication, this step will attempt
to find the OAuth medata then run the OAuth authentication flow.
"""
try:
authorization_server = await async_discover_oauth_config(
self.hass, self.data[CONF_URL]
)
except TimeoutConnectError:
return self.async_abort(reason="timeout_connect")
except CannotConnect:
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
else:
_LOGGER.info("OAuth configuration: %s", authorization_server)
self.data.update(
{
CONF_AUTHORIZATION_URL: authorization_server.authorize_url,
CONF_TOKEN_URL: authorization_server.token_url,
}
)
return await self.async_step_credentials_choice()
def authorization_server(self) -> AuthorizationServer:
"""Return the authorization server provided by the MCP server."""
return AuthorizationServer(
self.data[CONF_AUTHORIZATION_URL],
self.data[CONF_TOKEN_URL],
)
async def async_step_credentials_choice(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step to ask they user if they would like to add credentials.
This is needed since we can't automatically assume existing credentials
should be used given they may be for another existing server.
"""
with authorization_server_context(self.authorization_server()):
if not await async_get_implementations(self.hass, self.DOMAIN):
return await self.async_step_new_credentials()
return self.async_show_menu(
step_id="credentials_choice",
menu_options=["pick_implementation", "new_credentials"],
)
async def async_step_new_credentials(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step to take the frontend flow to enter new credentials."""
return self.async_abort(reason="missing_credentials")
async def async_step_pick_implementation(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the pick implementation step.
This exists to dynamically set application credentials Authorization Server
based on the values form the OAuth discovery step.
"""
with authorization_server_context(self.authorization_server()):
return await super().async_step_pick_implementation(user_input)
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an entry for the flow.
Ok to override if you want to fetch extra info or even add another step.
"""
config_entry_data = {
**self.data,
**data,
}
async def token_manager() -> str:
return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
try:
info = await validate_input(self.hass, config_entry_data, token_manager)
except TimeoutConnectError:
return self.async_abort(reason="timeout_connect")
except CannotConnect:
return self.async_abort(reason="cannot_connect")
except MissingCapabilities:
return self.async_abort(reason="missing_capabilities")
except Exception:
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
# Unique id based on the application credentials OAuth Client ID
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=config_entry_data
)
await self.async_set_unique_id(config_entry_data["auth_implementation"])
return self.async_create_entry(
title=info["title"],
data=config_entry_data,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
config_entry = self._get_reauth_entry()
self.data = {**config_entry.data}
self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment]
self.hass, config_entry
)
return await self.async_step_auth()
class InvalidUrl(HomeAssistantError):
"""Error to indicate the URL format is invalid."""

View File

@@ -1,3 +1,7 @@
"""Constants for the Model Context Protocol integration."""
DOMAIN = "mcp"
CONF_ACCESS_TOKEN = "access_token"
CONF_AUTHORIZATION_URL = "authorization_url"
CONF_TOKEN_URL = "token_url"

View File

@@ -1,7 +1,7 @@
"""Types for the Model Context Protocol integration."""
import asyncio
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
import datetime
import logging
@@ -15,7 +15,7 @@ from voluptuous_openapi import convert_to_voluptuous
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers import llm
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.json import JsonObjectType
@@ -27,16 +27,28 @@ _LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = datetime.timedelta(minutes=30)
TIMEOUT = 10
TokenManager = Callable[[], Awaitable[str]]
@asynccontextmanager
async def mcp_client(url: str) -> AsyncGenerator[ClientSession]:
async def mcp_client(
url: str,
token_manager: TokenManager | None = None,
) -> AsyncGenerator[ClientSession]:
"""Create a server-sent event MCP client.
This is an asynccontext manager that exists to wrap other async context managers
so that the coordinator has a single object to manage.
"""
headers: dict[str, str] = {}
if token_manager is not None:
token = await token_manager()
headers["Authorization"] = f"Bearer {token}"
try:
async with sse_client(url=url) as streams, ClientSession(*streams) as session:
async with (
sse_client(url=url, headers=headers) as streams,
ClientSession(*streams) as session,
):
await session.initialize()
yield session
except ExceptionGroup as err:
@@ -53,12 +65,14 @@ class ModelContextProtocolTool(llm.Tool):
description: str | None,
parameters: vol.Schema,
server_url: str,
token_manager: TokenManager | None = None,
) -> None:
"""Initialize the tool."""
self.name = name
self.description = description
self.parameters = parameters
self.server_url = server_url
self.token_manager = token_manager
async def async_call(
self,
@@ -69,7 +83,7 @@ class ModelContextProtocolTool(llm.Tool):
"""Call the tool."""
try:
async with asyncio.timeout(TIMEOUT):
async with mcp_client(self.server_url) as session:
async with mcp_client(self.server_url, self.token_manager) as session:
result = await session.call_tool(
tool_input.tool_name, tool_input.tool_args
)
@@ -87,7 +101,12 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
token_manager: TokenManager | None = None,
) -> None:
"""Initialize ModelContextProtocolCoordinator."""
super().__init__(
hass,
@@ -96,6 +115,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
config_entry=config_entry,
update_interval=UPDATE_INTERVAL,
)
self.token_manager = token_manager
async def _async_update_data(self) -> list[llm.Tool]:
"""Fetch data from API endpoint.
@@ -105,11 +125,20 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
"""
try:
async with asyncio.timeout(TIMEOUT):
async with mcp_client(self.config_entry.data[CONF_URL]) as session:
async with mcp_client(
self.config_entry.data[CONF_URL], self.token_manager
) as session:
result = await session.list_tools()
except TimeoutError as error:
_LOGGER.debug("Timeout when listing tools: %s", error)
raise UpdateFailed(f"Timeout when listing tools: {error}") from error
except httpx.HTTPStatusError as error:
_LOGGER.debug("Error communicating with API: %s", error)
if error.response.status_code == 401 and self.token_manager is not None:
raise ConfigEntryAuthFailed(
"The MCP server requires authentication"
) from error
raise UpdateFailed(f"Error communicating with API: {error}") from error
except httpx.HTTPError as err:
_LOGGER.debug("Error communicating with API: %s", err)
raise UpdateFailed(f"Error communicating with API: {err}") from err
@@ -129,6 +158,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
tool.description,
parameters,
self.config_entry.data[CONF_URL],
self.token_manager,
)
)
return tools

View File

@@ -3,6 +3,7 @@
"name": "Model Context Protocol",
"codeowners": ["@allenporter"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/mcp",
"iot_class": "local_polling",
"quality_scale": "silver",

View File

@@ -44,9 +44,7 @@ rules:
parallel-updates:
status: exempt
comment: Integration does not have platforms.
reauthentication-flow:
status: exempt
comment: Integration does not support authentication.
reauthentication-flow: done
test-coverage: done
# Gold

View File

@@ -8,6 +8,15 @@
"data_description": {
"url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse"
}
},
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
"data": {
"implementation": "Credentials"
},
"data_description": {
"implementation": "The credentials to use for the OAuth2 flow"
}
}
},
"error": {
@@ -17,9 +26,15 @@
"invalid_url": "Must be a valid MCP server URL e.g. https://example.com/sse"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"missing_capabilities": "The MCP server does not support a required capability (Tools)",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}

View File

@@ -344,7 +344,7 @@
},
"repeat": {
"options": {
"off": "Off",
"off": "[%key:common::state::off%]",
"all": "Repeat all",
"one": "Repeat one"
}

View File

@@ -88,11 +88,11 @@
},
"duplicate_entity_entry": {
"title": "Modbus {sub_1} address {sub_2} is duplicate, second entry not loaded.",
"description": "An address can only be associated with one entity, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue."
"description": "An address can only be associated with one entity. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue."
},
"duplicate_entity_name": {
"title": "Modbus {sub_1} is duplicate, second entry not loaded.",
"description": "A entity name must be unique, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue."
"description": "An entity name must be unique. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue."
},
"no_entities": {
"title": "Modbus {sub_1} contain no entities, entry not loaded.",

View File

@@ -72,8 +72,8 @@
"connection": {
"name": "Connection status",
"state": {
"connected": "Connected",
"disconnected": "Disconnected",
"connected": "[%key:common::state::connected%]",
"disconnected": "[%key:common::state::disconnected%]",
"connecting": "Connecting",
"disconnecting": "Disconnecting"
}

View File

@@ -399,6 +399,9 @@ class MqttAttributesMixin(Entity):
_attributes_extra_blocked: frozenset[str] = frozenset()
_attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None
_message_callback: Callable[
[MessageCallbackType, set[str] | None, ReceiveMessage], None
]
def __init__(self, config: ConfigType) -> None:
"""Initialize the JSON attributes mixin."""
@@ -433,7 +436,7 @@ class MqttAttributesMixin(Entity):
CONF_JSON_ATTRS_TOPIC: {
"topic": self._attributes_config.get(CONF_JSON_ATTRS_TOPIC),
"msg_callback": partial(
self._message_callback, # type: ignore[attr-defined]
self._message_callback,
self._attributes_message_received,
{"_attr_extra_state_attributes"},
),
@@ -482,6 +485,10 @@ class MqttAttributesMixin(Entity):
class MqttAvailabilityMixin(Entity):
"""Mixin used for platforms that report availability."""
_message_callback: Callable[
[MessageCallbackType, set[str] | None, ReceiveMessage], None
]
def __init__(self, config: ConfigType) -> None:
"""Initialize the availability mixin."""
self._availability_sub_state: dict[str, EntitySubscription] = {}
@@ -547,7 +554,7 @@ class MqttAvailabilityMixin(Entity):
f"availability_{topic}": {
"topic": topic,
"msg_callback": partial(
self._message_callback, # type: ignore[attr-defined]
self._message_callback,
self._availability_message_received,
{"available"},
),

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

@@ -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

@@ -46,7 +46,7 @@
"global_override": {
"name": "Global override",
"state": {
"away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
"away": "[%key:common::state::not_home%]",
"comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
"eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
"none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::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

@@ -23,14 +23,10 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
DEFAULT_SCAN_INTERVAL,
DOMAIN,
INTEGRATION_SUPPORTED_COMMANDS,
PLATFORMS,
)
from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS
NUT_FAKE_SERIAL = ["unknown", "blank"]
@@ -68,7 +64,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
alias = config.get(CONF_ALIAS)
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
if CONF_SCAN_INTERVAL in entry.options:
current_options = {**entry.options}
current_options.pop(CONF_SCAN_INTERVAL)
hass.config_entries.async_update_entry(entry, options=current_options)
data = PyNUTData(host, port, alias, username, password)
@@ -101,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
config_entry=entry,
name="NUT resource status",
update_method=async_update_data,
update_interval=timedelta(seconds=scan_interval),
update_interval=timedelta(seconds=60),
always_update=False,
)
@@ -122,6 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
if unique_id is None:
unique_id = entry.entry_id
elif entry.unique_id is None:
hass.config_entries.async_update_entry(entry, unique_id=unique_id)
if username is not None and password is not None:
# Dynamically add outlet integration commands
additional_integration_commands = set()
@@ -155,10 +157,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
coordinator, data, unique_id, user_available_commands
)
connections: set[tuple[str, str]] | None = None
if data.device_info.mac_address is not None:
connections = {(CONNECTION_NETWORK_MAC, data.device_info.mac_address)}
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, unique_id)},
connections=connections,
name=data.name.title(),
manufacturer=data.device_info.manufacturer,
model=data.device_info.model,
@@ -246,6 +253,7 @@ class NUTDeviceInfo:
model_id: str | None = None
firmware: str | None = None
serial: str | None = None
mac_address: str | None = None
device_location: str | None = None
@@ -309,9 +317,18 @@ class PyNUTData:
model_id: str | None = self._status.get("device.part")
firmware = _firmware_from_status(self._status)
serial = _serial_from_status(self._status)
mac_address: str | None = self._status.get("device.macaddr")
if mac_address is not None:
mac_address = format_mac(mac_address.rstrip().replace(" ", ":"))
device_location: str | None = self._status.get("device.location")
return NUTDeviceInfo(
manufacturer, model, model_id, firmware, serial, device_location
manufacturer,
model,
model_id,
firmware,
serial,
mac_address,
device_location,
)
async def _async_get_status(self) -> dict[str, str]:

View File

@@ -9,27 +9,21 @@ from typing import Any
from aionut import NUTError, NUTLoginError
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_ALIAS,
CONF_BASE,
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_SCAN_INTERVAL,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import PyNUTData
from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN
from . import PyNUTData, _unique_id_from_status
from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -125,6 +119,11 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
if self._host_port_alias_already_configured(nut_config):
return self.async_abort(reason="already_configured")
if unique_id := _unique_id_from_status(info["available_resources"]):
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
title = _format_host_port_alias(nut_config)
return self.async_create_entry(title=title, data=nut_config)
@@ -147,8 +146,13 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
self.nut_config.update(user_input)
if self._host_port_alias_already_configured(nut_config):
return self.async_abort(reason="already_configured")
_, errors, placeholders = await self._async_validate_or_error(nut_config)
info, errors, placeholders = await self._async_validate_or_error(nut_config)
if not errors:
if unique_id := _unique_id_from_status(info["available_resources"]):
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
title = _format_host_port_alias(nut_config)
return self.async_create_entry(title=title, data=nut_config)
@@ -230,32 +234,3 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema(AUTH_SCHEMA),
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Get the options flow for this handler."""
return OptionsFlowHandler()
class OptionsFlowHandler(OptionsFlow):
"""Handle a option flow for nut."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle options flow."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
scan_interval = self.config_entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
)
base_schema = {
vol.Optional(CONF_SCAN_INTERVAL, default=scan_interval): vol.All(
vol.Coerce(int), vol.Clamp(min=10, max=300)
)
}
return self.async_show_form(step_id="init", data_schema=vol.Schema(base_schema))

View File

@@ -19,8 +19,6 @@ DEFAULT_PORT = 3493
KEY_STATUS = "ups.status"
KEY_STATUS_DISPLAY = "ups.status.display"
DEFAULT_SCAN_INTERVAL = 60
STATE_TYPES = {
"OL": "Online",
"OB": "On Battery",

View File

@@ -38,15 +38,6 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"options": {
"step": {
"init": {
"data": {
"scan_interval": "Scan Interval (seconds)"
}
}
}
},
"device_automation": {
"action_type": {
"beeper_disable": "Disable UPS beeper/buzzer",

View File

@@ -6,6 +6,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, PLATFORMS
@@ -31,7 +32,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool:
"""Set up Ohme from a config entry."""
client = OhmeApiClient(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD])
client = OhmeApiClient(
email=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
session=async_get_clientsession(hass),
)
try:
await client.async_login()

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

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/ohme/",
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["ohme==1.4.1"]
"quality_scale": "platinum",
"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

@@ -75,6 +75,6 @@ rules:
comment: |
Not supported by the API. Accounts and devices have a one-to-one relationship.
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
async-dependency: done
inject-websession: done
strict-typing: done

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