Compare commits

..

148 Commits

Author SHA1 Message Date
jbouwh 4ad5ce6d52 Set climate state to off if is_on is False 2025-01-01 13:17:57 +00:00
Jan Bouwhuis a117a3cba9 Update homeassistant/components/climate/__init__.py
Co-authored-by: Allen Porter <allen@thebends.org>
2024-12-31 21:52:52 +01:00
jbouwh e2d9ca9cd9 Allow a climate entity to have an independent on / off state attribute 2024-12-30 21:57:19 +00:00
Dan Raper a0fb6df5ba Add battery sensor to ohme (#134222)
* Add battery sensor to ohme

* Forgot the snapshots!

* Add translation key to battery

* Change car to vehicle and fix snapshot tests

* Fix snapshot again - not sure what was going on with my local dev env
2024-12-30 20:15:11 +01:00
Bram Kragten 04020d5a56 Update frontend to 20241230.0 (#134284) 2024-12-30 20:04:50 +01:00
Norbert Rittel f785b17314 Fix two descriptions of yeelight actions (#134282) 2024-12-30 19:22:12 +01:00
Andrew Jackson 6631c57cfb Bump aiomealie to 0.9.5 (#134274) 2024-12-30 17:47:58 +01:00
Norbert Rittel bc76dc3c34 Remove excessive period at end of action name (#134272) 2024-12-30 16:22:30 +01:00
Ludovic BOUÉ ea4931ca3a Bump Python Matter server to 7.0.0 (Matter 1.4) (#132502)
* Matter 1.4 rename BridgedDevice device type

BREAKING change in the client: BridgedDevice is renamed to BridgedNode in the device types with Matter 1.4

* `ColorMode` enum type is renamed to `ColorModeEnum`

* Item `ColorTemperature` renamed to `ColorTemperatureMireds`

* Update ColorControl bitmaps and attributes

* Bump Python Matter server to 7.0.0 (Matter 1.4)

* Bump requirements to Python Matter server to 7.0.0
2024-12-30 15:41:14 +01:00
Arne Keller dd20204bf0 ollama: update to 0.4.5 (#134265) 2024-12-30 14:42:46 +01:00
Norbert Rittel ef46c62bc6 Make triggers and condition for monetary sensor consistent (#131184) 2024-12-30 13:47:16 +01:00
Alberto Geniola 2bb6e03a36 Bump elmax-api (#133845) 2024-12-30 13:46:53 +01:00
G Johansson 2288f89415 Fix duplicate sensor disk entities in Systemmonitor (#134139) 2024-12-30 13:38:48 +01:00
Josef Zweck e7ab5afc14 Bump pylamarzocco to 1.4.5 (#134259)
* Bump pylamarzocco to 1.4.4

* Bump pylamarzocco to 1.4.5

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-12-30 12:27:32 +01:00
Adam Goode 4db88dfaff Quickly process unavailable metrics in Prometheus (#133219) 2024-12-30 12:05:33 +01:00
Joost Lekkerkerker 906c95048c Record LG WebOS TV Quality scale (#133732)
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2024-12-30 11:45:44 +01:00
Manu df38c1b1d7 Remove deprecated yaml import from OTP integration (#134196) 2024-12-30 11:12:16 +01:00
tronikos af97bf1c5f Fix 400 This voice does not support speaking rate or pitch parameters at this time for Google Cloud Journey voices (#134255) 2024-12-30 09:20:35 +01:00
tronikos a7c2d96ecf Avoid KeyError for ignored entries in async_step_zeroconf of Android TV Remote (#134250) 2024-12-30 10:13:51 +02:00
Noah Husby 1b06b4e45b Remove unused translations from Russound RIO (#134246) 2024-12-30 10:11:37 +02:00
Manu b74b9bc360 Bump habiticalib to v0.3.2 (#134244) 2024-12-30 10:10:18 +02:00
Brett Adams 810689ce66 Handle missing application credentials in Tesla Fleet (#134237)
* Handle missing application credentials

* Add tests

* Test reauth starts

* Only catch ValueError
2024-12-29 22:21:18 -08:00
G Johansson 249d93574a Set Scrape sensor unavailable when errors (#134143) 2024-12-29 22:59:57 +01:00
Michael e2c59f276a Bump aiopegelonline to 0.1.1 (#134230)
bump aiopegelonline to 0.1.1
2024-12-29 21:36:49 +01:00
Manu 9804e8aa98 Add reauth flow to Habitica integration (#131676)
* Add reauth flow to Habitica integration

* tests, invalid_credentials string

* test only api_key

* section consts

* test config entry

* test reauth is triggered

* set reauthentication-flow to done

* use consts in tests

* reauth_entry

* changes

* fix import

* changes
2024-12-29 21:12:36 +01:00
Paul Daumlechner 53e69af088 Bump pyvlx to 0.2.26 (#115483) 2024-12-29 10:00:26 -10:00
tronikos 1530edbe20 Bump opower to 0.8.7 (#134228)
* Bump opower to 0.8.7

* update deps
2024-12-29 11:44:33 -08:00
Paulus Schoutsen 7dbf32d693 Bump frontend to 20241229.0 (#134225) 2024-12-29 13:35:46 -05:00
Michael Hansen 49646ad994 Bump VoIP utils to 0.2.2 (#134219) 2024-12-29 11:56:27 -06:00
G Johansson 1e652db37f Use config entry runtime data in Open-Meteo (#134198) 2024-12-29 18:16:41 +01:00
Dan Raper 88d366b0c5 Add slot list service to ohme (#134170)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-12-29 18:07:12 +01:00
Lucas Gasenzer 65147f8d4c Fix Wake on LAN Port input as Box instead of Slider (#134216) 2024-12-29 18:03:41 +01:00
Simone Chemelli 52b919101a Bump aiocomelit to 0.10.1 (#134214) 2024-12-29 17:30:52 +01:00
Aaron Bach 24fd74d839 Change SimpliSafe websocket reconnection log to DEBUG-level (#134063)
* Change SimpliSafe websocket reconnection log to `DEBUG`-level

* revert
2024-12-29 11:23:44 -05:00
Marc Mueller 2599faa622 Fix method subtyping [helpers] (#134213) 2024-12-29 17:16:38 +01:00
Marc Mueller 3df91cfba5 Fix method subtyping [recorder] (#134212) 2024-12-29 17:16:11 +01:00
Marc Mueller d3fab42c85 Fix method subtyping [knx] (#134211) 2024-12-29 16:41:23 +01:00
Marc Mueller beb881492a Fix method subtyping [elkm1] (#134210) 2024-12-29 16:40:51 +01:00
Matthias Alphart 9d7c7f9fcf Update knx-frontend to 2024.12.26.233449 (#134184) 2024-12-29 16:39:37 +01:00
Shay Levy 419307a7c4 Bump aioswitcher to 6.0.0 (#134185) 2024-12-29 15:42:33 +01:00
G Johansson 409dc4ad48 Move coordinator to own file in Open-Meteo (#134197) 2024-12-29 15:25:40 +01:00
Michael 7704ef95a4 Make feedreader recoverable (#134202)
raise ConfigEntryNotReady on connection errors during setup
2024-12-29 15:08:15 +01:00
Manu 0db07a033b Migrate Habitica integration to habiticalib (#131032)
* Migrate data to habiticalib

* Add habiticalib to init and coordinator

* Migrate Habitica config flow to habiticalib

* migrate init to habiticalib

* migrate buttons to habiticalib

* migrate switch to habiticalib

* update habiticalib

* cast_skill action

* migrate update_score

* migrate transformation items action

* migrate quest actions

* fix fixture errors

* Migrate coordinator data and content

* bump habiticalib

* Remove habitipy and use wrapper in habiticalub

* changes

* some fixes

* minor refactoring

* class_needed annotation

* Update diagnostics

* do integration setup in coordinator setup

* small changes

* raise HomeAssistantError for TooManyRequestsError

* fix docstring

* update tests

* changes to tests/snapshots

* fix update_todo_item
2024-12-29 15:00:31 +01:00
Joost Lekkerkerker 4717eb3142 Bump python-overseerr to 0.4.0 (#134192) 2024-12-29 15:46:30 +02:00
Joost Lekkerkerker c23f5c9f2c Make elevenlabs recoverable (#134094)
* Make elevenlabs recoverable

* Add tests for entry setup

* Use the same fixtures for setup and config flow

* Update tests/components/elevenlabs/test_setup.py

Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com>

---------

Co-authored-by: Simon Sorg <simon.sorg@student.hpi.de>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com>
2024-12-29 14:26:59 +01:00
Michael 873b078bb3 Make PEGELONLINE recoverable (#134199) 2024-12-29 14:07:45 +01:00
Manu 0dd93a18c5 Add button platform to IronOS integration (#133678)
* Add button platform to IronOS integration

* Add tests

* load platform

* refactor

* update tests
2024-12-29 12:39:13 +01:00
Maikel Punie da96e2077b Add Velbus Button tests (#134186)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-12-29 11:55:52 +01:00
Manu 1d69cf11a5 Bump pynecil to v3.0.1 (#134174) 2024-12-29 10:06:29 +02:00
Manu adb1fbbbc4 Add switch platform to IronOS integration (#133691)
* Add switch platform

* Add tests

* prevent switch bouncing

* some changes

* icons

* update tests

* changes
2024-12-28 21:59:06 +01:00
G Johansson 645f2e44b9 Fix Nord Pool empty response (#134033)
* Fix Nord Pool empty response

* Mods

* reset validate prices
2024-12-28 21:38:04 +01:00
Artur Pragacz b3aede611a Fix Onkyo volume rounding (#134157) 2024-12-28 21:34:01 +01:00
jb101010-2 72a96249b1 Suez_water: clear quality scale (#134027)
* Suez_water: clear quality scale

Revert invalid done rules and mark inapplicable ones as exempted.

* Mark entity disabled as todo

* Mark devices as todo

* missing push

* Update homeassistant/components/suez_water/quality_scale.yaml

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update quality_scale.yaml

* Update quality_scale.yaml again

---------

Co-authored-by: Josef Zweck <josef@zweck.dev>
2024-12-28 20:59:11 +01:00
Joost Lekkerkerker 80dbce14ec Add binary sensor to Tile (#134153) 2024-12-28 16:49:14 +01:00
Manu 0376f75ee3 Bump pynecil to v3.0.0 (#134151) 2024-12-28 16:48:28 +01:00
jb101010-2 e58bd62c68 Suez_water: use meter id as unique_id (#133959)
* Suez_water: use meter id as unique_id

* Review fixes

* No more afraid check :)

* review again

* Apply suggestions from code review

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2024-12-28 16:25:10 +01:00
Matthias Alphart 6dbcd130b0 Add quality_scale.yaml for KNX (#133937)
* Add quality_scale.yaml

* Update quality_scale.yaml
2024-12-28 16:24:49 +01:00
Andrew Jackson 4639f57014 Remove deprecated Mastodon yaml config import (#134040)
* Remove Mastodon yaml import

* Revert removal of async_migrate_entry
2024-12-28 16:22:32 +01:00
G Johansson 4080455c12 Use x,y in roborock action call (#134133)
* Use x,y in roborock action call

* Fix description
2024-12-28 16:12:09 +01:00
Joost Lekkerkerker df7d518f38 Add versions to Tile device (#134150)
* Add versions to Tile device

* Add versions to Tile device
2024-12-28 16:04:36 +01:00
Joost Lekkerkerker 47adfb574f Bump python-overseerr to 0.3.0 (#134147)
Bump Overseerr to 0.3.0
2024-12-28 15:44:15 +01:00
Joost Lekkerkerker 4c5d0c2ec4 Add Tile device tracker tests (#134137) 2024-12-28 15:36:56 +01:00
G Johansson 4febe43021 Add missing device classes in scrape (#134141) 2024-12-28 15:36:23 +01:00
Maikel Punie af13979855 Add Velbus binary sensor tests (#134132)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-12-28 14:57:48 +01:00
Marc Mueller d9f2140df3 Add ClassVar annotation for singleton patterns (#134135) 2024-12-28 13:17:15 +01:00
Joost Lekkerkerker cc80108629 Bump yt-dlp to 2024.12.23 (#134131) 2024-12-28 13:13:07 +01:00
Joost Lekkerkerker 16af76b968 Add Tile device tests (#134138) 2024-12-28 13:10:13 +01:00
Joost Lekkerkerker 590f0ce61f Refactor Tile tests (#134130) 2024-12-28 12:37:21 +01:00
Allen Porter 14059c6df8 Remove unused parameters from function calls in rainbird (#134124)
Remove unused parameters from rainbird function calls
2024-12-28 11:34:27 +00:00
Joost Lekkerkerker 268c21addd Add Overseerr integration (#133981)
* Add Overseerr integration

* Add Overseerr integration

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix
2024-12-28 11:50:36 +01:00
Andre Lengwenus 565fa4ea1f Remove incorrect device check in LCN events (#134116) 2024-12-28 09:26:49 +01:00
Raj Laud 28cd7f2473 Bump pysqueezebox to v0.11.1 (#134097) 2024-12-28 09:24:22 +01:00
Noah Husby aceb1b39ba Add mute support to Russound RIO (#134118) 2024-12-28 09:22:13 +01:00
Allen Porter 6edf06f8a4 Converge stream av open methods, options, and error handling (#134020)
* Converge stream av open methods, options, and error handling

* Remove exception that is never thrown

* Update exceptions thrown in generic tests

* Increase stream test coverage
2024-12-27 18:47:33 -08:00
Noah Husby 07ae9b15d0 Bump aiorussound to 4.2.0 (#134117) 2024-12-27 18:23:57 -08:00
G Johansson d676169b04 Cleanup devices in Nord Pool from reconfiguration (#134043)
* Cleanup devices in Nord Pool from reconfiguration

* Mods

* Mod
2024-12-27 21:33:37 +01:00
Noah Husby 24ce3d7daa Remove deprecated yaml import for Russound RIO (#134072) 2024-12-27 21:27:33 +01:00
Joost Lekkerkerker 417e736746 Migrate Tile to use entry.runtime_data (#134107) 2024-12-27 21:25:36 +01:00
Cyrill Raccaud bb8d4ca255 Add unit test for sensors in swiss public transport (#134115)
* add unit test for sensors

* clean up
2024-12-27 21:21:45 +01:00
Joost Lekkerkerker 375af6cb1c Introduce base entity for Tile (#134109) 2024-12-27 21:18:01 +01:00
Jan Bouwhuis 263e0acd3a Set PARALLEL_UPDATES for incomfort entity platforms (#134110) 2024-12-27 20:43:30 +01:00
Erwin Douna da531d0e4e Bump Tado to 0.18.5 (#133988) 2024-12-27 20:26:19 +01:00
Joost Lekkerkerker 844e36c8fe Bump python-homeassistant-analytics to 0.8.1 (#134101) 2024-12-27 20:21:12 +01:00
Joost Lekkerkerker 9976c07f89 Remove YAML import from Tile (#134108) 2024-12-27 20:15:48 +01:00
Aaron Bach 7df9d2e938 Bump pytile to 2024.12.0 (#134103) 2024-12-27 20:04:35 +01:00
Joost Lekkerkerker 52318f5f37 Extract Tile coordinator in separate file (#134104) 2024-12-27 19:30:13 +01:00
Joost Lekkerkerker b9c2b3f7e3 Remove Tile unique id migration (#134106) 2024-12-27 19:25:10 +01:00
Andrew Sayre a9ff5b8007 Bump pyheos to v0.8.0 (#134069)
Bump pyheos and update usage
2024-12-27 11:01:35 -06:00
Joost Lekkerkerker 7076ba7c9d Make google tasks recoverable (#134092) 2024-12-27 08:52:33 -08:00
Josef Zweck 5e0088feaa Add azure_data_explorer to microsoft brand (#134088) 2024-12-27 15:36:07 +01:00
Franck Nijhof f8399b2c0f Revert "Add state_class to EcoWittSensorTypes.DEGREE" (#134079) 2024-12-27 13:17:47 +01:00
Matthias Alphart 415fdf4956 Fix KNX config flow translations and add data descriptions (#134078)
* Fix KNX config flow translations and add data descriptions

* Update strings.json

* typo
2024-12-27 12:59:52 +01:00
Noah Husby ad89004189 Remove timeout from Russound RIO initialization (#134070) 2024-12-27 11:01:10 +01:00
Noah Husby b6afbe4b29 Bump aiorussound to 4.1.1 (#134058)
* Bump aiorussound to 4.1.1

* Trigger Build

* Trigger Build
2024-12-26 22:03:50 -06:00
Cyrill Raccaud 402340955e Fix swiss public transport line field none (#133964)
* fix #133116

The line can theoretically be none, when no line info is available (lets say walking sections first?)

* fix line field

* add unit test with missing line field
2024-12-27 00:24:47 +01:00
Raphael Hehl b2a160d926 Roborock Add vacuum_goto service (#133994)
* Roborock Add vacuum_goto service to control vacuum movement to specified coordinates

* roborock Add type specification for x_coord and y_coord in vacuum_goto service

* roborock Add get_current_position service to retrieve vacuum's current coordinates

* Rename vacuum services for clarity and consistency

* Apply suggestions from code review

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Add integration field to vacuum service targets for Roborock

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2024-12-27 00:20:09 +01:00
Thomas Kunzfeld 9840785363 Add state_class to EcoWittSensorTypes.DEGREE (#134004)
Add state_class to EcoWittSensorTypes.DEGREE (#129260)
2024-12-27 00:12:54 +01:00
jb101010-2 a53c92d4b5 Suez_water: remove redundant log on refresh failure (#134025)
Suez_water: remove redundent log on refresh failure
2024-12-27 00:05:28 +01:00
Marc Mueller adc97b6c15 Fix unifiprotect DeprecationWarnings in tests (#134060) 2024-12-26 23:50:03 +01:00
Jan Bouwhuis 7b2a5d0684 Remove mqtt publish templates after 6 months of deprecation (#134056) 2024-12-26 23:25:44 +01:00
Diogo Gomes acb511d395 Bump pyipma to 3.0.8 (#134055)
bump pyipma
2024-12-26 21:01:53 +00:00
Norbert Rittel c025390c6c Replace "service" with "action" plus fixed descriptions (#134053) 2024-12-26 15:39:18 -05:00
J. Nick Koston 942fbdedcf Ensure all states have been migrated to use timestamps (#134007) 2024-12-26 07:48:55 -10:00
Allen Porter 3bfb6707e9 Fix Nest ConfigEntry typing (#134021) 2024-12-26 09:27:20 -08:00
Norbert Rittel 5172139579 Use correct uppercase for abbreviations (#134028)
Fix the spelling of "SSDP" and "MAC" (address) to ensure proper translations.
2024-12-26 11:09:30 +01:00
Norbert Rittel cfb43c7b58 Fix typo in get_command action description (#134026) 2024-12-26 09:56:08 +01:00
Allen Porter 45657ece7c Improve Google Tasks error messages (#134023) 2024-12-26 09:53:20 +01:00
Erwin Douna f7fe2f2122 Tado update code owners (#133987)
Update code owners
2024-12-26 09:13:24 +01:00
Allen Porter c75222e63c Bump python-google-nest-sdm to 7.0.0 (#134016)
Update python-google-nest-sdm to 7.0.0
2024-12-26 00:03:44 -05:00
Brett Adams 299250ebec Bump Tesla Fleet API library (#134019)
Bump Tesla Fleet
2024-12-25 23:26:55 -05:00
Josef Zweck ed8e242049 Bump pylamarzocco to 1.4.3 (#134008) 2024-12-25 16:25:13 -08:00
Cyrill Raccaud 95e4a40ad5 Update silver docs for swiss public transport (#134001)
update docs
2024-12-25 21:36:30 +01:00
Christopher Fenner e61717ce7a Fulfill IQS rule docs-removal-instructions in ViCare integration (#133982)
update iqs state
2024-12-25 15:30:33 +01:00
Cyrill Raccaud 73b6bd8bd3 Add config flow data description to swiss public transport (#133997)
* add config flow data description

* improve strings
2024-12-25 15:20:09 +01:00
Cyrill Raccaud 60774c69cd Add clear shopping list button for Cookidoo (#133583)
* add clear button

* set clear button to disabled per default

* add actions exception
2024-12-25 14:58:19 +01:00
Cyrill Raccaud c383b41a12 Add parallel updates to swiss public transport (#133996)
add parallel updates
2024-12-25 14:55:34 +01:00
J. Nick Koston 05a8b773b9 Bump numpy to 2.2.1 (#133844)
* Bump numpy to 2.2.1

changelog: https://github.com/numpy/numpy/compare/v2.2.0...v2.2.1

* make sure ninja is up to date

* Revert "make sure ninja is up to date"

This reverts commit a26dd8b768.

* test

* Revert "test"

This reverts commit 972f40e3ee.

* try a single build

* try a single build

* Revert "Revert "test""

This reverts commit ec282ce021.

* Revert "Revert "Revert "test"""

This reverts commit 315599cbae.

* Revert "try a single build"

This reverts commit 63529dd2c5.

* Revert "try a single build"

This reverts commit 7058ae9288.
2024-12-25 11:27:00 +02:00
G-Two 1bee423c22 Bump subarulink to 0.7.13 (#133970) 2024-12-25 10:13:04 +02:00
Marc Mueller 687afd23bc Add pip wheel build constraints to fix numpy builds (#133962) 2024-12-24 15:06:21 -10:00
cdnninja 0020c48a15 Update pyvesync version (#131433) 2024-12-24 17:51:40 +01:00
Bram Kragten 760cbcc596 Update frontend to 20241224.0 (#133963) 2024-12-24 16:41:36 +01:00
Philipp Danner da8f4e5b57 fix "Slow" response leads to "Could not find a charging station" #124129 (#133889)
fix #124129
2024-12-24 14:00:34 +01:00
Claudio Ruggeri - CR-Tech 5c0659c8df Fix reload modbus component issue (#133820)
fix issue 116675
2024-12-24 13:57:18 +01:00
Marc Mueller 15806c2af6 Update Jinja2 to 3.1.5 (#133951) 2024-12-24 13:44:09 +01:00
Maikel Punie 97d8d16cc5 Bump velbusaio to 2024.12.3 (#133939) 2024-12-24 12:35:22 +02:00
Khole 33435fa36f Hive: Fix error when device goes offline (#133848) 2024-12-24 10:42:35 +01:00
Joost Lekkerkerker 6fc1cfded9 Use SignedSession in Xbox (#133938) 2024-12-24 10:17:02 +01:00
Franck Nijhof a9d6a42781 Update apprise to v1.9.1 (#133936) 2024-12-24 10:15:21 +01:00
Kevin Worrel f2a706ecf7 Make screenlogic state enums lowercase (#133866) 2024-12-24 09:12:18 +01:00
G-Two 4a2ae7f6fd Stop using shared aiohttp client session for Subaru integration (#133931) 2024-12-24 08:59:51 +01:00
Franck Nijhof 771ead9d7b Prevent imports from tests in core codebase (#133928)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2024-12-24 08:40:05 +01:00
Jordi 2d5e2aa4b4 Add Harvey virtual integration (#133874)
Add harvey virtual integration
2024-12-24 08:01:50 +01:00
Franck Nijhof 6f11524b84 Remove myself as codeowner from Tuya integration (#133921) 2024-12-24 07:55:44 +01:00
Dave T 561f319e3b Fix missing % in string for generic camera (#133925)
Fix missing % in generic camera string
2024-12-24 07:45:13 +01:00
Franck Nijhof 0c9ec4b699 Fix Peblar import in data coordinator (#133926) 2024-12-24 07:42:48 +01:00
Brett Adams cbb2930805 Slow down polling in Teslemetry (#133924) 2024-12-24 01:59:36 +01:00
Franck Nijhof aa29a93fbe Remove myself as codeowner from Plugwise (#133920) 2024-12-24 01:34:23 +01:00
J. Nick Koston ff4ba553c4 Sort integration platforms preload list (#133905)
* Sort integration platforms preload list

https://github.com/home-assistant/core/pull/133856#discussion_r1895385026

* sort

* Sort them all

---------

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
2024-12-23 23:38:59 +01:00
Thomas55555 2f101c5054 Catch ClientConnectorError and TimeOutError in APSystems (#132027) 2024-12-23 22:49:59 +01:00
karwosts 72e2b835d9 Fix a history stats bug when window and tracked state change simultaneously (#133770) 2024-12-23 22:47:26 +01:00
Martin Mrazik 8f6e4cd294 Map RGB+CCT to RGB for WLED (#133900) 2024-12-23 22:26:38 +01:00
Mick Vleeshouwer bd0edd4996 Revise codeowners for Overkiz (#133784) 2024-12-23 22:24:22 +01:00
J. Nick Koston 3f441e7090 Ensure cloud and recorder backup platforms do not have to wait for the import executor (#133907)
* Ensure cloud and recorder backup platforms do not have to wait for the import executor

partially fixes #133904

* backup.backup as well
2024-12-23 22:19:28 +01:00
Abílio Costa 253098d79c Mark missing IQS requirements for Idasen Desk as done (#133910) 2024-12-23 21:38:27 +01:00
Abílio Costa 53ebf84339 Add cronsim to default dependencies (#133913) 2024-12-23 21:34:36 +01:00
J. Nick Koston 7cfbc3eeae Fix duplicate call to async_register_preload_platform (#133909) 2024-12-23 09:20:44 -10:00
Franck Nijhof 8d32531bc1 Bump version to 2025.2.0dev0 (#133893) 2024-12-23 17:54:32 +01:00
Simon 30d95f37d8 Add removal instructions to ElevenLabs (#133895) 2024-12-23 18:37:19 +02:00
427 changed files with 10778 additions and 9847 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 11
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 9
HA_SHORT_VERSION: "2025.1"
HA_SHORT_VERSION: "2025.2"
DEFAULT_PYTHON: "3.12"
ALL_PYTHON_VERSIONS: "['3.12', '3.13']"
# 10.3 is the oldest supported version
+29
View File
@@ -76,8 +76,20 @@ jobs:
# Use C-Extension for SQLAlchemy
echo "REQUIRE_SQLALCHEMY_CEXT=1"
# Add additional pip wheel build constraints
echo "PIP_CONSTRAINT=build_constraints.txt"
) > .env_file
- name: Write pip wheel build constraints
run: |
(
# ninja 1.11.1.2 + 1.11.1.3 seem to be broken on at least armhf
# this caused the numpy builds to fail
# https://github.com/scikit-build/ninja-python-distributions/issues/274
echo "ninja==1.11.1.1"
) > build_constraints.txt
- name: Upload env_file
uses: actions/upload-artifact@v4.5.0
with:
@@ -86,6 +98,13 @@ jobs:
include-hidden-files: true
overwrite: true
- name: Upload build_constraints
uses: actions/upload-artifact@v4.5.0
with:
name: build_constraints
path: ./build_constraints.txt
overwrite: true
- name: Upload requirements_diff
uses: actions/upload-artifact@v4.5.0
with:
@@ -123,6 +142,11 @@ jobs:
with:
name: env_file
- name: Download build_constraints
uses: actions/download-artifact@v4.1.8
with:
name: build_constraints
- name: Download requirements_diff
uses: actions/download-artifact@v4.1.8
with:
@@ -167,6 +191,11 @@ jobs:
with:
name: env_file
- name: Download build_constraints
uses: actions/download-artifact@v4.1.8
with:
name: build_constraints
- name: Download requirements_diff
uses: actions/download-artifact@v4.1.8
with:
+1
View File
@@ -362,6 +362,7 @@ homeassistant.components.openuv.*
homeassistant.components.oralb.*
homeassistant.components.otbr.*
homeassistant.components.overkiz.*
homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.*
homeassistant.components.panel_custom.*
homeassistant.components.peblar.*
+10 -8
View File
@@ -1103,8 +1103,10 @@ build.json @home-assistant/supervisor
/tests/components/otbr/ @home-assistant/core
/homeassistant/components/ourgroceries/ @OnFreund
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
/homeassistant/components/overkiz/ @imicknl
/tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek
/tests/components/overseerr/ @joostlek
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -1135,8 +1137,8 @@ build.json @home-assistant/supervisor
/tests/components/plaato/ @JohNan
/homeassistant/components/plex/ @jjlawren
/tests/components/plex/ @jjlawren
/homeassistant/components/plugwise/ @CoMPaTech @bouwew @frenck
/tests/components/plugwise/ @CoMPaTech @bouwew @frenck
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
/tests/components/plugwise/ @CoMPaTech @bouwew
/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa
/tests/components/plum_lightpad/ @ColinHarrington @prystupa
/homeassistant/components/point/ @fredrike
@@ -1478,8 +1480,8 @@ build.json @home-assistant/supervisor
/tests/components/system_bridge/ @timmo001
/homeassistant/components/systemmonitor/ @gjohansson-ST
/tests/components/systemmonitor/ @gjohansson-ST
/homeassistant/components/tado/ @chiefdragon @erwindouna
/tests/components/tado/ @chiefdragon @erwindouna
/homeassistant/components/tado/ @erwindouna
/tests/components/tado/ @erwindouna
/homeassistant/components/tag/ @balloob @dmulcahey
/tests/components/tag/ @balloob @dmulcahey
/homeassistant/components/tailscale/ @frenck
@@ -1573,8 +1575,8 @@ build.json @home-assistant/supervisor
/tests/components/triggercmd/ @rvmey
/homeassistant/components/tts/ @home-assistant/core
/tests/components/tts/ @home-assistant/core
/homeassistant/components/tuya/ @Tuya @zlinoliver @frenck
/tests/components/tuya/ @Tuya @zlinoliver @frenck
/homeassistant/components/tuya/ @Tuya @zlinoliver
/tests/components/tuya/ @Tuya @zlinoliver
/homeassistant/components/twentemilieu/ @frenck
/tests/components/twentemilieu/ @frenck
/homeassistant/components/twinkly/ @dr1rrb @Robbie1221 @Olen
+1 -2
View File
@@ -89,7 +89,7 @@ from .helpers import (
)
from .helpers.dispatcher import async_dispatcher_send_internal
from .helpers.storage import get_internal_store_manager
from .helpers.system_info import async_get_system_info
from .helpers.system_info import async_get_system_info, is_official_image
from .helpers.typing import ConfigType
from .setup import (
# _setup_started is marked as protected to make it clear
@@ -106,7 +106,6 @@ from .util.async_ import create_eager_task
from .util.hass_dict import HassKey
from .util.logging import async_activate_log_queue_handler
from .util.package import async_get_user_site, is_docker_env, is_virtual_env
from .util.system_info import is_official_image
with contextlib.suppress(ImportError):
# Ensure anyio backend is imported to avoid it being imported in the event loop
+1
View File
@@ -2,6 +2,7 @@
"domain": "microsoft",
"name": "Microsoft",
"integrations": [
"azure_data_explorer",
"azure_devops",
"azure_event_hub",
"azure_service_bus",
+1 -1
View File
@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
"requirements": ["aioacaia==0.1.13"]
"requirements": ["aioacaia==0.1.11"]
}
@@ -44,12 +44,12 @@
}
},
"apps": {
"title": "Configure Android apps",
"description": "Configure application ID {app_id}",
"title": "Configure Android Apps",
"description": "Configure application id {app_id}",
"data": {
"app_name": "Application name",
"app_name": "Application Name",
"app_id": "Application ID",
"app_icon": "Application icon",
"app_icon": "Application Icon",
"app_delete": "Check to delete this application"
}
}
@@ -98,6 +98,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
scan_filter: str | None = None
all_identifiers: set[str]
atv: BaseConfig | None = None
atv_identifiers: list[str] | None = None
_host: str # host in zeroconf discovery info, should not be accessed by other flows
@@ -117,7 +118,6 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize a new AppleTVConfigFlow."""
self.credentials: dict[int, str | None] = {} # Protocol -> credentials
self.all_identifiers: set[str] = set()
@property
def device_identifier(self) -> str | None:
@@ -120,8 +120,6 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
"""Wait for the client to be ready."""
if not self.data or Attribute.MAC_ADDRESS not in self.data:
await self.client.read_mac_address()
data = await self.client.wait_for_response(
FunctionalDomain.IDENTIFICATION, 2, WAIT_TIMEOUT
)
@@ -132,9 +130,12 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
return False
if not self.data or Attribute.THERMOSTAT_MODES not in self.data:
await self.client.read_thermostat_iaq_available()
if not self.data or Attribute.NAME not in self.data:
await self.client.wait_for_response(
FunctionalDomain.IDENTIFICATION, 4, WAIT_TIMEOUT
)
if not self.data or Attribute.THERMOSTAT_MODES not in self.data:
await self.client.wait_for_response(
FunctionalDomain.CONTROL, 7, WAIT_TIMEOUT
)
@@ -143,16 +144,10 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
not self.data
or Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS not in self.data
):
await self.client.read_sensors()
await self.client.wait_for_response(
FunctionalDomain.SENSORS, 2, WAIT_TIMEOUT
)
await self.client.read_thermostat_status()
await self.client.read_iaq_status()
await ready_callback(True)
return True
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyaprilaire"],
"requirements": ["pyaprilaire==0.7.7"]
"requirements": ["pyaprilaire==0.7.4"]
}
@@ -75,7 +75,7 @@ class AudioBuffer:
class VoiceCommandSegmenter:
"""Segments an audio stream into voice commands."""
speech_seconds: float = 0.3
speech_seconds: float = 0.1
"""Seconds of speech before voice command has started."""
command_seconds: float = 1.0
@@ -31,8 +31,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"invalid_unique_id": "Impossible to determine a valid unique ID for the device",
"no_unique_id": "A device without a valid unique ID is already configured. Configuration of multiple instances is not possible"
"invalid_unique_id": "Impossible to determine a valid unique id for the device",
"no_unique_id": "A device without a valid unique id is already configured. Configuration of multiple instance is not possible"
}
},
"options": {
@@ -42,7 +42,7 @@
"consider_home": "Seconds to wait before considering a device away",
"track_unknown": "Track unknown / unnamed devices",
"interface": "The interface that you want statistics from (e.g. eth0, eth1 etc)",
"dnsmasq": "The location of the dnsmasq.leases file in the router",
"dnsmasq": "The location in the router of the dnsmasq.leases files",
"require_ip": "Devices must have IP (for access point mode)"
}
}
@@ -21,10 +21,8 @@ from .manager import (
BackupManager,
BackupPlatformProtocol,
BackupReaderWriter,
BackupReaderWriterError,
CoreBackupReaderWriter,
CreateBackupEvent,
IncorrectPasswordError,
ManagerBackup,
NewBackup,
WrittenBackup,
@@ -41,10 +39,8 @@ __all__ = [
"BackupAgentPlatformProtocol",
"BackupPlatformProtocol",
"BackupReaderWriter",
"BackupReaderWriterError",
"CreateBackupEvent",
"Folder",
"IncorrectPasswordError",
"LocalBackupAgent",
"NewBackup",
"WrittenBackup",
+3 -16
View File
@@ -7,7 +7,6 @@ from collections.abc import Callable
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
from enum import StrEnum
import random
from typing import TYPE_CHECKING, Self, TypedDict
from cronsim import CronSim
@@ -18,7 +17,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
from .const import LOGGER
from .models import BackupManagerError, Folder
from .models import Folder
if TYPE_CHECKING:
from .manager import BackupManager, ManagerBackup
@@ -29,10 +28,6 @@ if TYPE_CHECKING:
CRON_PATTERN_DAILY = "45 4 * * *"
CRON_PATTERN_WEEKLY = "45 4 * * {}"
# Randomize the start time of the backup by up to 60 minutes to avoid
# all backups running at the same time.
BACKUP_START_TIME_JITTER = 60 * 60
class StoredBackupConfig(TypedDict):
"""Represent the stored backup config."""
@@ -129,7 +124,6 @@ class BackupConfig:
def load(self, stored_config: StoredBackupConfig) -> None:
"""Load config."""
self.data = BackupConfigData.from_dict(stored_config)
self.data.retention.apply(self._manager)
self.data.schedule.apply(self._manager)
async def update(
@@ -166,13 +160,8 @@ class RetentionConfig:
def apply(self, manager: BackupManager) -> None:
"""Apply backup retention configuration."""
if self.days is not None:
LOGGER.debug(
"Scheduling next automatic delete of backups older than %s in 1 day",
self.days,
)
self._schedule_next(manager)
else:
LOGGER.debug("Unscheduling next automatic delete")
self._unschedule_next(manager)
def to_dict(self) -> StoredRetentionConfig:
@@ -329,13 +318,11 @@ class BackupSchedule:
password=config_data.create_backup.password,
with_automatic_settings=True,
)
except BackupManagerError as err:
LOGGER.error("Error creating backup: %s", err)
except Exception: # noqa: BLE001
# another more specific exception will be added
# and handled in the future
LOGGER.exception("Unexpected error creating automatic backup")
next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER))
LOGGER.debug("Scheduling next automatic backup at %s", next_time)
manager.remove_next_backup_event = async_track_point_in_time(
manager.hass, _create_backup, next_time
)
+69 -147
View File
@@ -46,11 +46,15 @@ from .const import (
EXCLUDE_FROM_BACKUP,
LOGGER,
)
from .models import AgentBackup, BackupManagerError, Folder
from .models import AgentBackup, Folder
from .store import BackupStore
from .util import make_backup_dir, read_backup, validate_password
class IncorrectPasswordError(HomeAssistantError):
"""Raised when the password is incorrect."""
@dataclass(frozen=True, kw_only=True, slots=True)
class NewBackup:
"""New backup class."""
@@ -241,14 +245,6 @@ class BackupReaderWriter(abc.ABC):
"""Restore a backup."""
class BackupReaderWriterError(HomeAssistantError):
"""Backup reader/writer error."""
class IncorrectPasswordError(BackupReaderWriterError):
"""Raised when the password is incorrect."""
class BackupManager:
"""Define the format that backup managers can have."""
@@ -377,9 +373,7 @@ class BackupManager:
)
for result in pre_backup_results:
if isinstance(result, Exception):
raise BackupManagerError(
f"Error during pre-backup: {result}"
) from result
raise result
async def async_post_backup_actions(self) -> None:
"""Perform post backup actions."""
@@ -392,9 +386,7 @@ class BackupManager:
)
for result in post_backup_results:
if isinstance(result, Exception):
raise BackupManagerError(
f"Error during post-backup: {result}"
) from result
raise result
async def load_platforms(self) -> None:
"""Load backup platforms."""
@@ -430,22 +422,11 @@ class BackupManager:
return_exceptions=True,
)
for idx, result in enumerate(sync_backup_results):
if isinstance(result, BackupReaderWriterError):
# writer errors will affect all agents
# no point in continuing
raise BackupManagerError(str(result)) from result
if isinstance(result, BackupAgentError):
LOGGER.error("Error uploading to %s: %s", agent_ids[idx], result)
agent_errors[agent_ids[idx]] = result
continue
if isinstance(result, Exception):
# trap bugs from agents
agent_errors[agent_ids[idx]] = result
LOGGER.error("Unexpected error: %s", result, exc_info=result)
continue
if isinstance(result, BaseException):
raise result
LOGGER.exception(
"Error during backup upload - %s", result, exc_info=result
)
return agent_errors
async def async_get_backups(
@@ -468,7 +449,7 @@ class BackupManager:
agent_errors[agent_ids[idx]] = result
continue
if isinstance(result, BaseException):
raise result # unexpected error
raise result
for agent_backup in result:
if (backup_id := agent_backup.backup_id) not in backups:
if known_backup := self.known_backups.get(backup_id):
@@ -518,7 +499,7 @@ class BackupManager:
agent_errors[agent_ids[idx]] = result
continue
if isinstance(result, BaseException):
raise result # unexpected error
raise result
if not result:
continue
if backup is None:
@@ -582,7 +563,7 @@ class BackupManager:
agent_errors[agent_ids[idx]] = result
continue
if isinstance(result, BaseException):
raise result # unexpected error
raise result
if not agent_errors:
self.known_backups.remove(backup_id)
@@ -597,7 +578,7 @@ class BackupManager:
) -> None:
"""Receive and store a backup file from upload."""
if self.state is not BackupManagerState.IDLE:
raise BackupManagerError(f"Backup manager busy: {self.state}")
raise HomeAssistantError(f"Backup manager busy: {self.state}")
self.async_on_backup_event(
ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS)
)
@@ -671,7 +652,6 @@ class BackupManager:
include_homeassistant=include_homeassistant,
name=name,
password=password,
raise_task_error=True,
with_automatic_settings=with_automatic_settings,
)
assert self._backup_finish_task
@@ -689,12 +669,11 @@ class BackupManager:
include_homeassistant: bool,
name: str | None,
password: str | None,
raise_task_error: bool = False,
with_automatic_settings: bool = False,
) -> NewBackup:
"""Initiate generating a backup."""
if self.state is not BackupManagerState.IDLE:
raise BackupManagerError(f"Backup manager busy: {self.state}")
raise HomeAssistantError(f"Backup manager busy: {self.state}")
if with_automatic_settings:
self.config.data.last_attempted_automatic_backup = dt_util.now()
@@ -713,7 +692,6 @@ class BackupManager:
include_homeassistant=include_homeassistant,
name=name,
password=password,
raise_task_error=raise_task_error,
with_automatic_settings=with_automatic_settings,
)
except Exception:
@@ -736,81 +714,57 @@ class BackupManager:
include_homeassistant: bool,
name: str | None,
password: str | None,
raise_task_error: bool,
with_automatic_settings: bool,
) -> NewBackup:
"""Initiate generating a backup."""
if not agent_ids:
raise BackupManagerError("At least one agent must be selected")
if invalid_agents := [
agent_id for agent_id in agent_ids if agent_id not in self.backup_agents
]:
raise BackupManagerError(f"Invalid agents selected: {invalid_agents}")
raise HomeAssistantError("At least one agent must be selected")
if any(agent_id not in self.backup_agents for agent_id in agent_ids):
raise HomeAssistantError("Invalid agent selected")
if include_all_addons and include_addons:
raise BackupManagerError(
raise HomeAssistantError(
"Cannot include all addons and specify specific addons"
)
backup_name = (
name
or f"{"Automatic" if with_automatic_settings else "Custom"} backup {HAVERSION}"
or f"{"Automatic" if with_automatic_settings else "Custom"} {HAVERSION}"
)
try:
(
new_backup,
self._backup_task,
) = await self._reader_writer.async_create_backup(
agent_ids=agent_ids,
backup_name=backup_name,
extra_metadata={
"instance_id": await instance_id.async_get(self.hass),
"with_automatic_settings": with_automatic_settings,
},
include_addons=include_addons,
include_all_addons=include_all_addons,
include_database=include_database,
include_folders=include_folders,
include_homeassistant=include_homeassistant,
on_progress=self.async_on_backup_event,
password=password,
)
except BackupReaderWriterError as err:
raise BackupManagerError(str(err)) from err
backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
new_backup, self._backup_task = await self._reader_writer.async_create_backup(
agent_ids=agent_ids,
backup_name=backup_name,
extra_metadata={
"instance_id": await instance_id.async_get(self.hass),
"with_automatic_settings": with_automatic_settings,
},
include_addons=include_addons,
include_all_addons=include_all_addons,
include_database=include_database,
include_folders=include_folders,
include_homeassistant=include_homeassistant,
on_progress=self.async_on_backup_event,
password=password,
)
self._backup_finish_task = self.hass.async_create_task(
self._async_finish_backup(agent_ids, with_automatic_settings),
name="backup_manager_finish_backup",
)
if not raise_task_error:
def log_finish_task_error(task: asyncio.Task[None]) -> None:
if task.done() and not task.cancelled() and (err := task.exception()):
if isinstance(err, BackupManagerError):
LOGGER.error("Error creating backup: %s", err)
else:
LOGGER.error("Unexpected error: %s", err, exc_info=err)
backup_finish_task.add_done_callback(log_finish_task_error)
return new_backup
async def _async_finish_backup(
self, agent_ids: list[str], with_automatic_settings: bool
) -> None:
"""Finish a backup."""
if TYPE_CHECKING:
assert self._backup_task is not None
backup_success = False
try:
written_backup = await self._backup_task
except Exception as err:
except Exception as err: # noqa: BLE001
LOGGER.debug("Generating backup failed", exc_info=err)
self.async_on_backup_event(
CreateBackupEvent(stage=None, state=CreateBackupState.FAILED)
)
if with_automatic_settings:
self._update_issue_backup_failed()
if isinstance(err, BackupReaderWriterError):
raise BackupManagerError(str(err)) from err
raise # unexpected error
else:
LOGGER.debug(
"Generated new backup with backup_id %s, uploading to agents %s",
@@ -823,40 +777,28 @@ class BackupManager:
state=CreateBackupState.IN_PROGRESS,
)
)
try:
agent_errors = await self._async_upload_backup(
backup=written_backup.backup,
agent_ids=agent_ids,
open_stream=written_backup.open_stream,
)
finally:
await written_backup.release_stream()
self.known_backups.add(written_backup.backup, agent_errors)
if not agent_errors:
if with_automatic_settings:
# create backup was successful, update last_completed_automatic_backup
self.config.data.last_completed_automatic_backup = dt_util.now()
self.store.save()
backup_success = True
agent_errors = await self._async_upload_backup(
backup=written_backup.backup,
agent_ids=agent_ids,
open_stream=written_backup.open_stream,
)
await written_backup.release_stream()
if with_automatic_settings:
# create backup was successful, update last_completed_automatic_backup
self.config.data.last_completed_automatic_backup = dt_util.now()
self.store.save()
self._update_issue_after_agent_upload(agent_errors)
self.known_backups.add(written_backup.backup, agent_errors)
# delete old backups more numerous than copies
# try this regardless of agent errors above
await delete_backups_exceeding_configured_count(self)
self.async_on_backup_event(
CreateBackupEvent(stage=None, state=CreateBackupState.COMPLETED)
)
finally:
self._backup_task = None
self._backup_finish_task = None
self.async_on_backup_event(
CreateBackupEvent(
stage=None,
state=CreateBackupState.COMPLETED
if backup_success
else CreateBackupState.FAILED,
)
)
self.async_on_backup_event(IdleEvent())
async def async_restore_backup(
@@ -872,7 +814,7 @@ class BackupManager:
) -> None:
"""Initiate restoring a backup."""
if self.state is not BackupManagerState.IDLE:
raise BackupManagerError(f"Backup manager busy: {self.state}")
raise HomeAssistantError(f"Backup manager busy: {self.state}")
self.async_on_backup_event(
RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS)
@@ -887,9 +829,6 @@ class BackupManager:
restore_folders=restore_folders,
restore_homeassistant=restore_homeassistant,
)
self.async_on_backup_event(
RestoreBackupEvent(stage=None, state=RestoreBackupState.COMPLETED)
)
except Exception:
self.async_on_backup_event(
RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED)
@@ -912,7 +851,7 @@ class BackupManager:
"""Initiate restoring a backup."""
agent = self.backup_agents[agent_id]
if not await agent.async_get_backup(backup_id):
raise BackupManagerError(
raise HomeAssistantError(
f"Backup {backup_id} not found in agent {agent_id}"
)
@@ -1085,11 +1024,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
backup_id = _generate_backup_id(date_str, backup_name)
if include_addons or include_all_addons or include_folders:
raise BackupReaderWriterError(
raise HomeAssistantError(
"Addons and folders are not supported by core backup"
)
if not include_homeassistant:
raise BackupReaderWriterError("Home Assistant must be included in backup")
raise HomeAssistantError("Home Assistant must be included in backup")
backup_task = self._hass.async_create_task(
self._async_create_backup(
@@ -1160,13 +1099,6 @@ class CoreBackupReaderWriter(BackupReaderWriter):
password,
local_agent_tar_file_path,
)
except (BackupManagerError, OSError, tarfile.TarError, ValueError) as err:
# BackupManagerError from async_pre_backup_actions
# OSError from file operations
# TarError from tarfile
# ValueError from json_bytes
raise BackupReaderWriterError(str(err)) from err
else:
backup = AgentBackup(
addons=[],
backup_id=backup_id,
@@ -1184,15 +1116,12 @@ class CoreBackupReaderWriter(BackupReaderWriter):
async_add_executor_job = self._hass.async_add_executor_job
async def send_backup() -> AsyncIterator[bytes]:
f = await async_add_executor_job(tar_file_path.open, "rb")
try:
f = await async_add_executor_job(tar_file_path.open, "rb")
try:
while chunk := await async_add_executor_job(f.read, 2**20):
yield chunk
finally:
await async_add_executor_job(f.close)
except OSError as err:
raise BackupReaderWriterError(str(err)) from err
while chunk := await async_add_executor_job(f.read, 2**20):
yield chunk
finally:
await async_add_executor_job(f.close)
async def open_backup() -> AsyncIterator[bytes]:
return send_backup()
@@ -1200,20 +1129,14 @@ class CoreBackupReaderWriter(BackupReaderWriter):
async def remove_backup() -> None:
if local_agent_tar_file_path:
return
try:
await async_add_executor_job(tar_file_path.unlink, True)
except OSError as err:
raise BackupReaderWriterError(str(err)) from err
await async_add_executor_job(tar_file_path.unlink, True)
return WrittenBackup(
backup=backup, open_stream=open_backup, release_stream=remove_backup
)
finally:
# Inform integrations the backup is done
try:
await manager.async_post_backup_actions()
except BackupManagerError as err:
raise BackupReaderWriterError(str(err)) from err
await manager.async_post_backup_actions()
def _mkdir_and_generate_backup_contents(
self,
@@ -1283,7 +1206,6 @@ class CoreBackupReaderWriter(BackupReaderWriter):
if self._local_agent_id in agent_ids:
local_agent = manager.local_backup_agents[self._local_agent_id]
tar_file_path = local_agent.get_backup_path(backup.backup_id)
await async_add_executor_job(make_backup_dir, tar_file_path.parent)
await async_add_executor_job(shutil.move, temp_file, tar_file_path)
else:
tar_file_path = temp_file
@@ -1327,11 +1249,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
"""
if restore_addons or restore_folders:
raise BackupReaderWriterError(
raise HomeAssistantError(
"Addons and folders are not supported in core restore"
)
if not restore_homeassistant and not restore_database:
raise BackupReaderWriterError(
raise HomeAssistantError(
"Home Assistant or database must be included in restore"
)
@@ -1376,7 +1298,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
)
await self._hass.async_add_executor_job(_write_restore_file)
await self._hass.services.async_call("homeassistant", "restart", blocking=True)
await self._hass.services.async_call("homeassistant", "restart", {})
def _generate_backup_id(date: str, name: str) -> str:
@@ -6,8 +6,6 @@ from dataclasses import asdict, dataclass
from enum import StrEnum
from typing import Any, Self
from homeassistant.exceptions import HomeAssistantError
@dataclass(frozen=True, kw_only=True)
class AddonInfo:
@@ -69,7 +67,3 @@ class AgentBackup:
protected=data["protected"],
size=data["size"],
)
class BackupManagerError(HomeAssistantError):
"""Backup manager error."""
+2 -2
View File
@@ -5,8 +5,8 @@
"description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
},
"automatic_backup_failed_upload_agents": {
"title": "Automatic backup could not be uploaded to the configured locations",
"description": "The automatic backup could not be uploaded to the configured locations {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
"title": "Automatic backup could not be uploaded to agents",
"description": "The automatic backup could not be uploaded to agents {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
}
},
"services": {
@@ -20,6 +20,6 @@
"bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.20.0",
"dbus-fast==2.24.3",
"habluetooth==3.7.0"
"habluetooth==3.6.0"
]
}
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bring",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["bring_api"],
"requirements": ["bring-api==0.9.1"]
}
@@ -12,7 +12,7 @@
}
},
"discovery_confirm": {
"description": "Do you want to set up {name}?"
"description": "Do you want to setup {name}?"
},
"reconfigure": {
"description": "Reconfigure your Cambridge Audio Streamer.",
@@ -28,7 +28,7 @@
"cannot_connect": "Failed to connect to Cambridge Audio device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect."
},
"abort": {
"wrong_device": "This Cambridge Audio device does not match the existing device ID. Please make sure you entered the correct IP address.",
"wrong_device": "This Cambridge Audio device does not match the existing device id. Please make sure you entered the correct IP address.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+5 -21
View File
@@ -516,19 +516,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Flag supported features."""
return self._attr_supported_features
@property
def supported_features_compat(self) -> CameraEntityFeature:
"""Return the supported features as CameraEntityFeature.
Remove this compatibility shim in 2025.1 or later.
"""
features = self.supported_features
if type(features) is int: # noqa: E721
new_features = CameraEntityFeature(features)
self._report_deprecated_supported_features_values(new_features)
return new_features
return features
@cached_property
def is_recording(self) -> bool:
"""Return true if the device is recording."""
@@ -582,7 +569,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
self._deprecate_attr_frontend_stream_type_logged = True
return self._attr_frontend_stream_type
if CameraEntityFeature.STREAM not in self.supported_features_compat:
if CameraEntityFeature.STREAM not in self.supported_features:
return None
if (
self._webrtc_provider
@@ -811,9 +798,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
async def async_internal_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_internal_added_to_hass()
self.__supports_stream = (
self.supported_features_compat & CameraEntityFeature.STREAM
)
self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM
await self.async_refresh_providers(write_state=False)
async def async_refresh_providers(self, *, write_state: bool = True) -> None:
@@ -853,7 +838,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]]
) -> _T | None:
"""Get first provider that supports this camera."""
if CameraEntityFeature.STREAM not in self.supported_features_compat:
if CameraEntityFeature.STREAM not in self.supported_features:
return None
return await fn(self.hass, self)
@@ -911,7 +896,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def camera_capabilities(self) -> CameraCapabilities:
"""Return the camera capabilities."""
frontend_stream_types = set()
if CameraEntityFeature.STREAM in self.supported_features_compat:
if CameraEntityFeature.STREAM in self.supported_features:
if self._supports_native_sync_webrtc or self._supports_native_async_webrtc:
# The camera has a native WebRTC implementation
frontend_stream_types.add(StreamType.WEB_RTC)
@@ -931,8 +916,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""
super().async_write_ha_state()
if self.__supports_stream != (
supports_stream := self.supported_features_compat
& CameraEntityFeature.STREAM
supports_stream := self.supported_features & CameraEntityFeature.STREAM
):
self.__supports_stream = supports_stream
self._invalidate_camera_capabilities_cache()
+2 -2
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import configparser
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, ClassVar
from urllib.parse import urlparse
import aiohttp
@@ -129,7 +129,7 @@ class ChromecastInfo:
class ChromeCastZeroconf:
"""Class to hold a zeroconf instance."""
__zconf: zeroconf.HaZeroconf | None = None
__zconf: ClassVar[zeroconf.HaZeroconf | None] = None
@classmethod
def set_zeroconf(cls, zconf: zeroconf.HaZeroconf) -> None:
@@ -240,6 +240,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"preset_mode",
"preset_modes",
"is_aux_heat",
"is_on",
"fan_mode",
"fan_modes",
"swing_mode",
@@ -280,6 +281,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_hvac_mode: HVACMode | None
_attr_hvac_modes: list[HVACMode]
_attr_is_aux_heat: bool | None
_attr_is_on: bool | None
_attr_max_humidity: float = DEFAULT_MAX_HUMIDITY
_attr_max_temp: float
_attr_min_humidity: float = DEFAULT_MIN_HUMIDITY
@@ -352,11 +354,33 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
hvac_mode = self.hvac_mode
if hvac_mode is None:
return None
if hasattr(self, "_attr_is_on") and self._attr_is_on is False:
return HVACMode.OFF.value
# Support hvac_mode as string for custom integration backwards compatibility
if not isinstance(hvac_mode, HVACMode):
return HVACMode(hvac_mode).value # type: ignore[unreachable]
return hvac_mode.value
@property
def is_on(self) -> bool | None:
"""Return True if the climate is turned on.
The climate's on/off state can be be controlled independently
from the hvac_action and hvac_mode if the _attr_is_on attribute is set.
If the _attr_is_on attribute is set, then return that value.
Otherwise, return True if hvac_action is not None and not HVACAction.OFF.
Return None if hvac_action is None,
otherwise return True if hvac_mode is not HVACMode.OFF.
"""
if hasattr(self, "_attr_is_on"):
return self._attr_is_on
if self.hvac_action is not None:
return self.hvac_action != HVACAction.OFF
if self.hvac_mode is None:
return None
return self.hvac_mode != HVACMode.OFF
@property
def precision(self) -> float:
"""Return the precision of the system."""
+23 -78
View File
@@ -2,12 +2,9 @@
from __future__ import annotations
import asyncio
import base64
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import hashlib
import logging
import random
from typing import Any, Self
from aiohttp import ClientError, ClientTimeout, StreamReader
@@ -26,11 +23,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .client import CloudClient
from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
_LOGGER = logging.getLogger(__name__)
_STORAGE_BACKUP = "backup"
_RETRY_LIMIT = 5
_RETRY_SECONDS_MIN = 60
_RETRY_SECONDS_MAX = 600
async def _b64md5(stream: AsyncIterator[bytes]) -> str:
@@ -143,55 +136,13 @@ class CloudBackupAgent(BackupAgent):
raise BackupAgentError("Failed to get download details") from err
try:
resp = await self._cloud.websession.get(
details["url"],
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
resp = await self._cloud.websession.get(details["url"])
resp.raise_for_status()
except ClientError as err:
raise BackupAgentError("Failed to download backup") from err
return ChunkAsyncStreamIterator(resp.content)
async def _async_do_upload_backup(
self,
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
filename: str,
base64md5hash: str,
metadata: dict[str, Any],
size: int,
) -> None:
"""Upload a backup."""
try:
details = await async_files_upload_details(
self._cloud,
storage_type=_STORAGE_BACKUP,
filename=filename,
metadata=metadata,
size=size,
base64md5hash=base64md5hash,
)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to get upload details") from err
try:
upload_status = await self._cloud.websession.put(
details["url"],
data=await open_stream(),
headers=details["headers"] | {"content-length": str(size)},
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
_LOGGER.log(
logging.DEBUG if upload_status.status < 400 else logging.WARNING,
"Backup upload status: %s",
upload_status.status,
)
upload_status.raise_for_status()
except (TimeoutError, ClientError) as err:
raise BackupAgentError("Failed to upload backup") from err
async def async_upload_backup(
self,
*,
@@ -208,34 +159,29 @@ class CloudBackupAgent(BackupAgent):
raise BackupAgentError("Cloud backups must be protected")
base64md5hash = await _b64md5(await open_stream())
filename = self._get_backup_filename()
metadata = backup.as_dict()
size = backup.size
tries = 1
while tries <= _RETRY_LIMIT:
try:
await self._async_do_upload_backup(
open_stream=open_stream,
filename=filename,
base64md5hash=base64md5hash,
metadata=metadata,
size=size,
)
break
except BackupAgentError as err:
if tries == _RETRY_LIMIT:
raise
tries += 1
retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX)
_LOGGER.info(
"Failed to upload backup, retrying (%s/%s) in %ss: %s",
tries,
_RETRY_LIMIT,
retry_timer,
err,
)
await asyncio.sleep(retry_timer)
try:
details = await async_files_upload_details(
self._cloud,
storage_type=_STORAGE_BACKUP,
filename=self._get_backup_filename(),
metadata=backup.as_dict(),
size=backup.size,
base64md5hash=base64md5hash,
)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to get upload details") from err
try:
upload_status = await self._cloud.websession.put(
details["url"],
data=await open_stream(),
headers=details["headers"] | {"content-length": str(backup.size)},
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
upload_status.raise_for_status()
except (TimeoutError, ClientError) as err:
raise BackupAgentError("Failed to upload backup") from err
async def async_delete_backup(
self,
@@ -262,7 +208,6 @@ class CloudBackupAgent(BackupAgent):
"""List backups."""
try:
backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP)
_LOGGER.debug("Cloud backups: %s", backups)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to list backups") from err
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/compensation",
"iot_class": "calculated",
"quality_scale": "legacy",
"requirements": ["numpy==2.2.0"]
"requirements": ["numpy==2.2.1"]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==2.1.0", "home-assistant-intents==2025.1.1"]
"requirements": ["hassil==2.0.5", "home-assistant-intents==2024.12.20"]
}
@@ -2,7 +2,7 @@
from __future__ import annotations
from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
from cookidoo_api import Cookidoo, CookidooConfig, CookidooLocalizationConfig
from homeassistant.const import (
CONF_COUNTRY,
@@ -16,23 +16,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.TODO]
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO]
async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool:
"""Set up Cookidoo from a config entry."""
localizations = await get_localization_options(
country=entry.data[CONF_COUNTRY].lower(),
language=entry.data[CONF_LANGUAGE],
)
cookidoo = Cookidoo(
async_get_clientsession(hass),
CookidooConfig(
email=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
localization=localizations[0],
localization=CookidooLocalizationConfig(
country_code=entry.data[CONF_COUNTRY].lower(),
language=entry.data[CONF_LANGUAGE],
),
),
)
@@ -0,0 +1,70 @@
"""Support for Cookidoo buttons."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from cookidoo_api import Cookidoo, CookidooException
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
from .entity import CookidooBaseEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class CookidooButtonEntityDescription(ButtonEntityDescription):
"""Describes cookidoo button entity."""
press_fn: Callable[[Cookidoo], Awaitable[None]]
TODO_CLEAR = CookidooButtonEntityDescription(
key="todo_clear",
translation_key="todo_clear",
press_fn=lambda client: client.clear_shopping_list(),
entity_registry_enabled_default=False,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: CookidooConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Cookidoo button entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities([CookidooButton(coordinator, TODO_CLEAR)])
class CookidooButton(CookidooBaseEntity, ButtonEntity):
"""Defines an Cookidoo button."""
entity_description: CookidooButtonEntityDescription
def __init__(
self,
coordinator: CookidooDataUpdateCoordinator,
description: CookidooButtonEntityDescription,
) -> None:
"""Initialize cookidoo button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
async def async_press(self) -> None:
"""Press the button."""
try:
await self.entity_description.press_fn(self.coordinator.cookidoo)
except CookidooException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="button_clear_todo_failed",
) from e
await self.coordinator.async_refresh()
@@ -10,6 +10,7 @@ from cookidoo_api import (
Cookidoo,
CookidooAuthException,
CookidooConfig,
CookidooLocalizationConfig,
CookidooRequestException,
get_country_options,
get_localization_options,
@@ -218,19 +219,18 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
else:
data_input[CONF_LANGUAGE] = (
await get_localization_options(country=data_input[CONF_COUNTRY].lower())
)[0].language # Pick any language to test login
localizations = await get_localization_options(
country=data_input[CONF_COUNTRY].lower(),
language=data_input[CONF_LANGUAGE],
)
)[0] # Pick any language to test login
session = async_get_clientsession(self.hass)
cookidoo = Cookidoo(
async_get_clientsession(self.hass),
session,
CookidooConfig(
email=data_input[CONF_EMAIL],
password=data_input[CONF_PASSWORD],
localization=localizations[0],
localization=CookidooLocalizationConfig(
country_code=data_input[CONF_COUNTRY].lower(),
language=data_input[CONF_LANGUAGE],
),
),
)
try:
@@ -1,5 +1,10 @@
{
"entity": {
"button": {
"todo_clear": {
"default": "mdi:cart-off"
}
},
"todo": {
"ingredient_list": {
"default": "mdi:cart-plus"
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/cookidoo",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["cookidoo_api"],
"quality_scale": "silver",
"requirements": ["cookidoo-api==0.12.2"]
"requirements": ["cookidoo-api==0.10.0"]
}
@@ -48,6 +48,11 @@
}
},
"entity": {
"button": {
"todo_clear": {
"name": "Clear shopping list and additional purchases"
}
},
"todo": {
"ingredient_list": {
"name": "Shopping list"
@@ -58,6 +63,9 @@
}
},
"exceptions": {
"button_clear_todo_failed": {
"message": "Failed to clear all items from the Cookidoo shopping list"
},
"todo_save_item_failed": {
"message": "Failed to save {name} to Cookidoo shopping list"
},
@@ -300,10 +300,6 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def supported_features(self) -> CoverEntityFeature:
"""Flag supported features."""
if (features := self._attr_supported_features) is not None:
if type(features) is int: # noqa: E721
new_features = CoverEntityFeature(features)
self._report_deprecated_supported_features_values(new_features)
return new_features
return features
supported_features = (
+1 -1
View File
@@ -266,7 +266,7 @@ class DeconzBaseLight[_LightDeviceT: Group | Light](
@property
def color_temp_kelvin(self) -> int | None:
"""Return the CT color value."""
if self._device.color_temp is None or self._device.color_temp == 0:
if self._device.color_temp is None:
return None
return color_temperature_mired_to_kelvin(self._device.color_temp)
@@ -50,7 +50,7 @@
"services": {
"get_command": {
"name": "Get command",
"description": "Send sa generic HTTP get command.",
"description": "Sends a generic HTTP get command.",
"fields": {
"command": {
"name": "Command",
@@ -57,11 +57,11 @@
"services": {
"get_gas_prices": {
"name": "Get gas prices",
"description": "Request gas prices from easyEnergy.",
"description": "Requests gas prices from easyEnergy.",
"fields": {
"config_entry": {
"name": "Config Entry",
"description": "The config entry to use for this service."
"description": "The configuration entry to use for this action."
},
"incl_vat": {
"name": "VAT Included",
@@ -79,7 +79,7 @@
},
"get_energy_usage_prices": {
"name": "Get energy usage prices",
"description": "Request usage energy prices from easyEnergy.",
"description": "Requests usage energy prices from easyEnergy.",
"fields": {
"config_entry": {
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]",
@@ -101,7 +101,7 @@
},
"get_energy_return_prices": {
"name": "Get energy return prices",
"description": "Request return energy prices from easyEnergy.",
"description": "Requests return energy prices from easyEnergy.",
"fields": {
"config_entry": {
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]",
@@ -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==10.1.0"]
"requirements": ["py-sucks==0.9.10", "deebot-client==10.0.1"]
}
@@ -13,7 +13,7 @@ rules:
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: todo
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: >
@@ -49,7 +49,7 @@ class ElkBinarySensor(ElkAttachedEntity, BinarySensorEntity):
_element: Zone
_attr_entity_registry_enabled_default = False
def _element_changed(self, _: Element, changeset: Any) -> None:
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
# Zone in NORMAL state is OFF; any other state is ON
self._attr_is_on = bool(
self._element.logical_status != ZoneLogicalStatus.NORMAL
+5 -5
View File
@@ -120,7 +120,7 @@ class ElkCounter(ElkSensor):
_attr_icon = "mdi:numeric"
_element: Counter
def _element_changed(self, _: Element, changeset: Any) -> None:
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
self._attr_native_value = self._element.value
@@ -153,7 +153,7 @@ class ElkKeypad(ElkSensor):
attrs["last_keypress"] = self._element.last_keypress
return attrs
def _element_changed(self, _: Element, changeset: Any) -> None:
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
self._attr_native_value = temperature_to_state(
self._element.temperature, UNDEFINED_TEMPERATURE
)
@@ -173,7 +173,7 @@ class ElkPanel(ElkSensor):
attrs["system_trouble_status"] = self._element.system_trouble_status
return attrs
def _element_changed(self, _: Element, changeset: Any) -> None:
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
if self._elk.is_connected():
self._attr_native_value = (
"Paused" if self._element.remote_programming_status else "Connected"
@@ -188,7 +188,7 @@ class ElkSetting(ElkSensor):
_attr_translation_key = "setting"
_element: Setting
def _element_changed(self, _: Element, changeset: Any) -> None:
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
self._attr_native_value = self._element.value
@property
@@ -257,7 +257,7 @@ class ElkZone(ElkSensor):
return UnitOfElectricPotential.VOLT
return None
def _element_changed(self, _: Element, changeset: Any) -> None:
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
if self._element.definition == ZoneType.TEMPERATURE:
self._attr_native_value = temperature_to_state(
self._element.temperature, UNDEFINED_TEMPERATURE
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["openwebif"],
"requirements": ["openwebifpy==4.3.1"]
"requirements": ["openwebifpy==4.3.0"]
}
@@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.0.0"]
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"]
}
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
from aioesphomeapi import APIClient, DeviceInfo
from bleak_esphome import connect_scanner
from bleak_esphome.backend.cache import ESPHomeBluetoothCache
from homeassistant.components.bluetooth import async_register_scanner
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
@@ -27,9 +28,10 @@ def async_connect_scanner(
entry_data: RuntimeEntryData,
cli: APIClient,
device_info: DeviceInfo,
cache: ESPHomeBluetoothCache,
) -> CALLBACK_TYPE:
"""Connect scanner."""
client_data = connect_scanner(cli, device_info, entry_data.available)
client_data = connect_scanner(cli, device_info, cache, entry_data.available)
entry_data.bluetooth_device = client_data.bluetooth_device
client_data.disconnect_callbacks = entry_data.disconnect_callbacks
scanner = client_data.scanner
@@ -6,6 +6,8 @@ from dataclasses import dataclass, field
from functools import cache
from typing import Self
from bleak_esphome.backend.cache import ESPHomeBluetoothCache
from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSONEncoder
@@ -20,6 +22,9 @@ class DomainData:
"""Define a class that stores global esphome data in hass.data[DOMAIN]."""
_stores: dict[str, ESPHomeStorage] = field(default_factory=dict)
bluetooth_cache: ESPHomeBluetoothCache = field(
default_factory=ESPHomeBluetoothCache
)
def get_entry_data(self, entry: ESPHomeConfigEntry) -> RuntimeEntryData:
"""Return the runtime entry data associated with this config entry.
+3 -1
View File
@@ -423,7 +423,9 @@ class ESPHomeManager:
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
entry_data.disconnect_callbacks.add(
async_connect_scanner(hass, entry_data, cli, device_info)
async_connect_scanner(
hass, entry_data, cli, device_info, self.domain_data.bluetooth_cache
)
)
if device_info.voice_assistant_feature_flags_compat(api_version) and (
@@ -18,7 +18,7 @@
"requirements": [
"aioesphomeapi==28.0.0",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.0.0"
"bleak-esphome==1.1.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
+1 -1
View File
@@ -23,10 +23,10 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.system_info import is_official_image
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.signal_type import SignalType
from homeassistant.util.system_info import is_official_image
DOMAIN = "ffmpeg"
@@ -2,11 +2,10 @@
from datetime import datetime as dt
import logging
from typing import Any
import jwt
from pyflick import FlickAPI
from pyflick.authentication import SimpleFlickAuth
from pyflick.authentication import AbstractFlickAuth
from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET
from homeassistant.config_entries import ConfigEntry
@@ -21,8 +20,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from .const import CONF_ACCOUNT_ID, CONF_SUPPLY_NODE_REF, CONF_TOKEN_EXPIRY
from .coordinator import FlickConfigEntry, FlickElectricDataCoordinator
from .const import CONF_TOKEN_EXPIRY, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -31,85 +29,36 @@ CONF_ID_TOKEN = "id_token"
PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: FlickConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Flick Electric from a config entry."""
auth = HassFlickAuth(hass, entry)
coordinator = FlickElectricDataCoordinator(
hass, FlickAPI(auth), entry.data[CONF_SUPPLY_NODE_REF]
)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = FlickAPI(auth)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: FlickConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug(
"Migrating configuration from version %s.%s",
config_entry.version,
config_entry.minor_version,
)
if config_entry.version > 2:
return False
if config_entry.version == 1:
api = FlickAPI(HassFlickAuth(hass, config_entry))
accounts = await api.getCustomerAccounts()
active_accounts = [
account for account in accounts if account["status"] == "active"
]
# A single active account can be auto-migrated
if (len(active_accounts)) == 1:
account = active_accounts[0]
new_data = {**config_entry.data}
new_data[CONF_ACCOUNT_ID] = account["id"]
new_data[CONF_SUPPLY_NODE_REF] = account["main_consumer"]["supply_node_ref"]
hass.config_entries.async_update_entry(
config_entry,
title=account["address"],
unique_id=account["id"],
data=new_data,
version=2,
)
return True
config_entry.async_start_reauth(hass, data={**config_entry.data})
return False
return True
class HassFlickAuth(SimpleFlickAuth):
class HassFlickAuth(AbstractFlickAuth):
"""Implementation of AbstractFlickAuth based on a Home Assistant entity config."""
def __init__(self, hass: HomeAssistant, entry: FlickConfigEntry) -> None:
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Flick authentication based on a Home Assistant entity config."""
super().__init__(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
client_id=entry.data.get(CONF_CLIENT_ID, DEFAULT_CLIENT_ID),
client_secret=entry.data.get(CONF_CLIENT_SECRET, DEFAULT_CLIENT_SECRET),
websession=aiohttp_client.async_get_clientsession(hass),
)
super().__init__(aiohttp_client.async_get_clientsession(hass))
self._entry = entry
self._hass = hass
async def _get_entry_token(self) -> dict[str, Any]:
async def _get_entry_token(self):
# No token saved, generate one
if (
CONF_TOKEN_EXPIRY not in self._entry.data
@@ -126,8 +75,13 @@ class HassFlickAuth(SimpleFlickAuth):
async def _update_token(self):
_LOGGER.debug("Fetching new access token")
token = await super().get_new_token(
self._username, self._password, self._client_id, self._client_secret
token = await self.get_new_token(
username=self._entry.data[CONF_USERNAME],
password=self._entry.data[CONF_PASSWORD],
client_id=self._entry.data.get(CONF_CLIENT_ID, DEFAULT_CLIENT_ID),
client_secret=self._entry.data.get(
CONF_CLIENT_SECRET, DEFAULT_CLIENT_SECRET
),
)
_LOGGER.debug("New token: %s", token)
@@ -1,18 +1,14 @@
"""Config Flow for Flick Electric integration."""
import asyncio
from collections.abc import Mapping
import logging
from typing import Any
from aiohttp import ClientResponseError
from pyflick import FlickAPI
from pyflick.authentication import AbstractFlickAuth, SimpleFlickAuth
from pyflick.authentication import AuthException, SimpleFlickAuth
from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET
from pyflick.types import APIException, AuthException, CustomerAccount
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
@@ -21,18 +17,12 @@ from homeassistant.const import (
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_ACCOUNT_ID, CONF_SUPPLY_NODE_REF, DOMAIN
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
LOGIN_SCHEMA = vol.Schema(
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
@@ -45,13 +35,10 @@ LOGIN_SCHEMA = vol.Schema(
class FlickConfigFlow(ConfigFlow, domain=DOMAIN):
"""Flick config flow."""
VERSION = 2
auth: AbstractFlickAuth
accounts: list[CustomerAccount]
data: dict[str, Any]
VERSION = 1
async def _validate_auth(self, user_input: Mapping[str, Any]) -> bool:
self.auth = SimpleFlickAuth(
async def _validate_input(self, user_input):
auth = SimpleFlickAuth(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
websession=aiohttp_client.async_get_clientsession(self.hass),
@@ -61,83 +48,22 @@ class FlickConfigFlow(ConfigFlow, domain=DOMAIN):
try:
async with asyncio.timeout(60):
token = await self.auth.async_get_access_token()
except (TimeoutError, ClientResponseError) as err:
token = await auth.async_get_access_token()
except TimeoutError as err:
raise CannotConnect from err
except AuthException as err:
raise InvalidAuth from err
return token is not None
async def async_step_select_account(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""Ask user to select account."""
errors = {}
if user_input is not None and CONF_ACCOUNT_ID in user_input:
self.data[CONF_ACCOUNT_ID] = user_input[CONF_ACCOUNT_ID]
self.data[CONF_SUPPLY_NODE_REF] = self._get_supply_node_ref(
user_input[CONF_ACCOUNT_ID]
)
try:
# Ensure supply node is active
await FlickAPI(self.auth).getPricing(self.data[CONF_SUPPLY_NODE_REF])
except (APIException, ClientResponseError):
errors["base"] = "cannot_connect"
except AuthException:
# We should never get here as we have a valid token
return self.async_abort(reason="no_permissions")
else:
# Supply node is active
return await self._async_create_entry()
try:
self.accounts = await FlickAPI(self.auth).getCustomerAccounts()
except (APIException, ClientResponseError):
errors["base"] = "cannot_connect"
active_accounts = [a for a in self.accounts if a["status"] == "active"]
if len(active_accounts) == 0:
return self.async_abort(reason="no_accounts")
if len(active_accounts) == 1:
self.data[CONF_ACCOUNT_ID] = active_accounts[0]["id"]
self.data[CONF_SUPPLY_NODE_REF] = self._get_supply_node_ref(
active_accounts[0]["id"]
)
return await self._async_create_entry()
return self.async_show_form(
step_id="select_account",
data_schema=vol.Schema(
{
vol.Required(CONF_ACCOUNT_ID): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(
value=account["id"], label=account["address"]
)
for account in active_accounts
],
mode=SelectSelectorMode.LIST,
)
)
}
),
errors=errors,
)
async def async_step_user(
self, user_input: Mapping[str, Any] | None = None
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle gathering login info."""
errors = {}
if user_input is not None:
try:
await self._validate_auth(user_input)
await self._validate_input(user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
@@ -146,61 +72,20 @@ class FlickConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self.data = dict(user_input)
return await self.async_step_select_account(user_input)
await self.async_set_unique_id(
f"flick_electric_{user_input[CONF_USERNAME]}"
)
self._abort_if_unique_id_configured()
return self.async_show_form(
step_id="user", data_schema=LOGIN_SCHEMA, errors=errors
)
async def async_step_reauth(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication."""
self.data = {**user_input}
return await self.async_step_user(user_input)
async def _async_create_entry(self) -> ConfigFlowResult:
"""Create an entry for the flow."""
await self.async_set_unique_id(self.data[CONF_ACCOUNT_ID])
account = self._get_account(self.data[CONF_ACCOUNT_ID])
if self.source == SOURCE_REAUTH:
# Migration completed
if self._get_reauth_entry().version == 1:
self.hass.config_entries.async_update_entry(
self._get_reauth_entry(),
unique_id=self.unique_id,
data=self.data,
version=self.VERSION,
return self.async_create_entry(
title=f"Flick Electric: {user_input[CONF_USERNAME]}",
data=user_input,
)
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
unique_id=self.unique_id,
title=account["address"],
data=self.data,
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=account["address"],
data=self.data,
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
def _get_account(self, account_id: str) -> CustomerAccount:
"""Get the account for the account ID."""
return next(a for a in self.accounts if a["id"] == account_id)
def _get_supply_node_ref(self, account_id: str) -> str:
"""Get the supply node ref for the account."""
return self._get_account(account_id)["main_consumer"][CONF_SUPPLY_NODE_REF]
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
@@ -3,8 +3,6 @@
DOMAIN = "flick_electric"
CONF_TOKEN_EXPIRY = "expires"
CONF_ACCOUNT_ID = "account_id"
CONF_SUPPLY_NODE_REF = "supply_node_ref"
ATTR_START_AT = "start_at"
ATTR_END_AT = "end_at"
@@ -1,47 +0,0 @@
"""Data Coordinator for Flick Electric."""
import asyncio
from datetime import timedelta
import logging
import aiohttp
from pyflick import FlickAPI, FlickPrice
from pyflick.types import APIException, AuthException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(minutes=5)
type FlickConfigEntry = ConfigEntry[FlickElectricDataCoordinator]
class FlickElectricDataCoordinator(DataUpdateCoordinator[FlickPrice]):
"""Coordinator for flick power price."""
def __init__(
self, hass: HomeAssistant, api: FlickAPI, supply_node_ref: str
) -> None:
"""Initialize FlickElectricDataCoordinator."""
super().__init__(
hass,
_LOGGER,
name="Flick Electric",
update_interval=SCAN_INTERVAL,
)
self.supply_node_ref = supply_node_ref
self._api = api
async def _async_update_data(self) -> FlickPrice:
"""Fetch pricing data from Flick Electric."""
try:
async with asyncio.timeout(60):
return await self._api.getPricing(self.supply_node_ref)
except AuthException as err:
raise ConfigEntryAuthFailed from err
except (APIException, aiohttp.ClientResponseError) as err:
raise UpdateFailed from err
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyflick"],
"requirements": ["PyFlick==1.1.3"]
"requirements": ["PyFlick==0.0.2"]
}
@@ -1,72 +1,74 @@
"""Support for Flick Electric Pricing data."""
import asyncio
from datetime import timedelta
from decimal import Decimal
import logging
from typing import Any
from pyflick import FlickAPI, FlickPrice
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CURRENCY_CENT, UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utcnow
from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT
from .coordinator import FlickConfigEntry, FlickElectricDataCoordinator
from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT, DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(minutes=5)
async def async_setup_entry(
hass: HomeAssistant,
entry: FlickConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Flick Sensor Setup."""
coordinator = entry.runtime_data
api: FlickAPI = hass.data[DOMAIN][entry.entry_id]
async_add_entities([FlickPricingSensor(coordinator)])
async_add_entities([FlickPricingSensor(api)], True)
class FlickPricingSensor(CoordinatorEntity[FlickElectricDataCoordinator], SensorEntity):
class FlickPricingSensor(SensorEntity):
"""Entity object for Flick Electric sensor."""
_attr_attribution = "Data provided by Flick Electric"
_attr_native_unit_of_measurement = f"{CURRENCY_CENT}/{UnitOfEnergy.KILO_WATT_HOUR}"
_attr_has_entity_name = True
_attr_translation_key = "power_price"
_attributes: dict[str, Any] = {}
def __init__(self, coordinator: FlickElectricDataCoordinator) -> None:
def __init__(self, api: FlickAPI) -> None:
"""Entity object for Flick Electric sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.supply_node_ref}_pricing"
self._api: FlickAPI = api
self._price: FlickPrice = None
@property
def native_value(self) -> Decimal:
def native_value(self):
"""Return the state of the sensor."""
# The API should return a unit price with quantity of 1.0 when no start/end time is provided
if self.coordinator.data.quantity != 1:
_LOGGER.warning(
"Unexpected quantity for unit price: %s", self.coordinator.data
)
return self.coordinator.data.cost * 100
return self._price.price
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
def extra_state_attributes(self):
"""Return the state attributes."""
components: dict[str, float] = {}
return self._attributes
for component in self.coordinator.data.components:
async def async_update(self) -> None:
"""Get the Flick Pricing data from the web service."""
if self._price and self._price.end_at >= utcnow():
return # Power price data is still valid
async with asyncio.timeout(60):
self._price = await self._api.getPricing()
_LOGGER.debug("Pricing data: %s", self._price)
self._attributes[ATTR_START_AT] = self._price.start_at
self._attributes[ATTR_END_AT] = self._price.end_at
for component in self._price.components:
if component.charge_setter not in ATTR_COMPONENTS:
_LOGGER.warning("Found unknown component: %s", component.charge_setter)
continue
components[component.charge_setter] = float(component.value * 100)
return {
ATTR_START_AT: self.coordinator.data.start_at,
ATTR_END_AT: self.coordinator.data.end_at,
**components,
}
self._attributes[component.charge_setter] = float(component.value)
@@ -9,12 +9,6 @@
"client_id": "Client ID (optional)",
"client_secret": "Client Secret (optional)"
}
},
"select_account": {
"title": "Select account",
"data": {
"account_id": "Account"
}
}
},
"error": {
@@ -23,10 +17,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"no_permissions": "Cannot get pricing for this account. Please check user permissions.",
"no_accounts": "No services are active on this Flick account"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}
},
"entity": {
@@ -214,18 +214,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self._options = options
await self.hass.async_add_executor_job(self.setup)
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
configuration_url=f"http://{self.host}",
connections={(dr.CONNECTION_NETWORK_MAC, self.mac)},
identifiers={(DOMAIN, self.unique_id)},
manufacturer="AVM",
model=self.model,
name=self.config_entry.title,
sw_version=self.current_firmware,
)
def setup(self) -> None:
"""Set up FritzboxTools class."""
+10 -1
View File
@@ -68,14 +68,23 @@ class FritzBoxBaseEntity:
"""Init device info class."""
self._avm_wrapper = avm_wrapper
self._device_name = device_name
self.mac_address = self._avm_wrapper.mac
@property
def mac_address(self) -> str:
"""Return the mac address of the main device."""
return self._avm_wrapper.mac
@property
def device_info(self) -> DeviceInfo:
"""Return the device information."""
return DeviceInfo(
configuration_url=f"http://{self._avm_wrapper.host}",
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)},
identifiers={(DOMAIN, self._avm_wrapper.unique_id)},
manufacturer="AVM",
model=self._avm_wrapper.model,
name=self._device_name,
sw_version=self._avm_wrapper.current_firmware,
)
@@ -1,7 +1,6 @@
{
"domain": "frontend",
"name": "Home Assistant Frontend",
"after_dependencies": ["backup"],
"codeowners": ["@home-assistant/frontend"],
"dependencies": [
"api",
@@ -21,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250109.0"]
"requirements": ["home-assistant-frontend==20241230.0"]
}
+52 -67
View File
@@ -255,10 +255,6 @@ async def async_test_and_preview_stream(
"""
if not (stream_source := info.get(CONF_STREAM_SOURCE)):
return None
# Import from stream.worker as stream cannot reexport from worker
# without forcing the av dependency on default_config
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.stream.worker import StreamWorkerError
if not isinstance(stream_source, template_helper.Template):
stream_source = template_helper.Template(stream_source, hass)
@@ -294,8 +290,6 @@ async def async_test_and_preview_stream(
f"{DOMAIN}.test_stream",
)
hls_provider = stream.add_provider(HLS_PROVIDER)
except StreamWorkerError as err:
raise InvalidStreamException("unknown_with_details", str(err)) from err
except PermissionError as err:
raise InvalidStreamException("stream_not_permitted") from err
except OSError as err:
@@ -349,6 +343,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the start of the config flow."""
errors = {}
description_placeholders = {}
hass = self.hass
if user_input:
# Secondary validation because serialised vol can't seem to handle this complexity:
@@ -364,6 +359,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
)
except InvalidStreamException as err:
errors[CONF_STREAM_SOURCE] = str(err)
if err.details:
errors["error_details"] = err.details
self.preview_stream = None
if not errors:
user_input[CONF_CONTENT_TYPE] = still_format
@@ -382,6 +379,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
# temporary preview for user to check the image
self.preview_cam = user_input
return await self.async_step_user_confirm()
if "error_details" in errors:
description_placeholders["error"] = errors.pop("error_details")
elif self.user_input:
user_input = self.user_input
else:
@@ -389,6 +388,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
data_schema=build_schema(user_input),
description_placeholders=description_placeholders,
errors=errors,
)
@@ -406,6 +406,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
title=self.title, data={}, options=self.user_input
)
register_preview(self.hass)
preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
return self.async_show_form(
step_id="user_confirm",
data_schema=vol.Schema(
@@ -413,6 +414,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Required(CONF_CONFIRMED_OK, default=False): bool,
}
),
description_placeholders={"preview_url": preview_url},
errors=None,
preview="generic_camera",
)
@@ -429,7 +431,6 @@ class GenericOptionsFlowHandler(OptionsFlow):
def __init__(self) -> None:
"""Initialize Generic IP Camera options flow."""
self.preview_cam: dict[str, Any] = {}
self.preview_stream: Stream | None = None
self.user_input: dict[str, Any] = {}
async def async_step_init(
@@ -437,45 +438,42 @@ class GenericOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult:
"""Manage Generic IP Camera options."""
errors: dict[str, str] = {}
description_placeholders = {}
hass = self.hass
if user_input:
# Secondary validation because serialised vol can't seem to handle this complexity:
if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get(
CONF_STREAM_SOURCE
):
errors["base"] = "no_still_image_or_stream_url"
else:
errors, still_format = await async_test_still(hass, user_input)
try:
self.preview_stream = await async_test_and_preview_stream(
hass, user_input
)
except InvalidStreamException as err:
errors[CONF_STREAM_SOURCE] = str(err)
self.preview_stream = None
if not errors:
user_input[CONF_CONTENT_TYPE] = still_format
still_url = user_input.get(CONF_STILL_IMAGE_URL)
if still_url is None:
# If user didn't specify a still image URL,
# The automatically generated still image that stream generates
# is always jpeg
still_format = "image/jpeg"
data = {
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
),
**user_input,
CONF_CONTENT_TYPE: still_format
or self.config_entry.options.get(CONF_CONTENT_TYPE),
}
self.user_input = data
# temporary preview for user to check the image
self.preview_cam = data
return await self.async_step_user_confirm()
elif self.user_input:
user_input = self.user_input
if user_input is not None:
errors, still_format = await async_test_still(
hass, self.config_entry.options | user_input
)
try:
await async_test_and_preview_stream(hass, user_input)
except InvalidStreamException as err:
errors[CONF_STREAM_SOURCE] = str(err)
if err.details:
errors["error_details"] = err.details
# Stream preview during options flow not yet implemented
still_url = user_input.get(CONF_STILL_IMAGE_URL)
if not errors:
if still_url is None:
# If user didn't specify a still image URL,
# The automatically generated still image that stream generates
# is always jpeg
still_format = "image/jpeg"
data = {
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
),
**user_input,
CONF_CONTENT_TYPE: still_format
or self.config_entry.options.get(CONF_CONTENT_TYPE),
}
self.user_input = data
# temporary preview for user to check the image
self.preview_cam = data
return await self.async_step_confirm_still()
if "error_details" in errors:
description_placeholders["error"] = errors.pop("error_details")
return self.async_show_form(
step_id="init",
data_schema=build_schema(
@@ -483,17 +481,15 @@ class GenericOptionsFlowHandler(OptionsFlow):
True,
self.show_advanced_options,
),
description_placeholders=description_placeholders,
errors=errors,
)
async def async_step_user_confirm(
async def async_step_confirm_still(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user clicking confirm after still preview."""
if user_input:
if ha_stream := self.preview_stream:
# Kill off the temp stream we created.
await ha_stream.stop()
if not user_input.get(CONF_CONFIRMED_OK):
return await self.async_step_init()
return self.async_create_entry(
@@ -501,22 +497,18 @@ class GenericOptionsFlowHandler(OptionsFlow):
data=self.user_input,
)
register_preview(self.hass)
preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
return self.async_show_form(
step_id="user_confirm",
step_id="confirm_still",
data_schema=vol.Schema(
{
vol.Required(CONF_CONFIRMED_OK, default=False): bool,
}
),
description_placeholders={"preview_url": preview_url},
errors=None,
preview="generic_camera",
)
@staticmethod
async def async_setup_preview(hass: HomeAssistant) -> None:
"""Set up preview WS API."""
websocket_api.async_register_command(hass, ws_start_preview)
class CameraImagePreview(HomeAssistantView):
"""Camera view to temporarily serve an image."""
@@ -558,7 +550,7 @@ class CameraImagePreview(HomeAssistantView):
{
vol.Required("type"): "generic_camera/start_preview",
vol.Required("flow_id"): str,
vol.Optional("flow_type"): vol.Any("config_flow", "options_flow"),
vol.Optional("flow_type"): vol.Any("config_flow"),
vol.Optional("user_input"): dict,
}
)
@@ -572,17 +564,10 @@ async def ws_start_preview(
_LOGGER.debug("Generating websocket handler for generic camera preview")
flow_id = msg["flow_id"]
flow: GenericIPCamConfigFlow | GenericOptionsFlowHandler
if msg.get("flow_type", "config_flow") == "config_flow":
flow = cast(
GenericIPCamConfigFlow,
hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001
)
else: # (flow type == "options flow")
flow = cast(
GenericOptionsFlowHandler,
hass.config_entries.options._progress.get(flow_id), # noqa: SLF001
)
flow = cast(
GenericIPCamConfigFlow,
hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001
)
user_input = flow.preview_cam
# Create an EntityPlatform, needed for name translations
@@ -67,11 +67,11 @@
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
}
},
"user_confirm": {
"title": "Confirmation",
"description": "Please wait for previews to load...",
"confirm_still": {
"title": "Preview",
"description": "![Camera Still Image Preview]({preview_url})",
"data": {
"confirmed_ok": "Everything looks good."
"confirmed_ok": "This image looks good."
}
}
},
@@ -34,18 +34,6 @@
"moderate": "Moderate",
"good": "Good",
"very_good": "Very good"
},
"state_attributes": {
"options": {
"state": {
"very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
"bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
"sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
}
}
}
},
"c6h6": {
@@ -63,18 +51,6 @@
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
},
"state_attributes": {
"options": {
"state": {
"very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
"bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
"sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
}
}
}
},
"o3_index": {
@@ -86,18 +62,6 @@
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
},
"state_attributes": {
"options": {
"state": {
"very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
"bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
"sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
}
}
}
},
"pm10_index": {
@@ -109,18 +73,6 @@
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
},
"state_attributes": {
"options": {
"state": {
"very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
"bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
"sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
}
}
}
},
"pm25_index": {
@@ -132,18 +84,6 @@
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
},
"state_attributes": {
"options": {
"state": {
"very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
"bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
"sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
}
}
}
},
"so2_index": {
@@ -155,18 +95,6 @@
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
},
"state_attributes": {
"options": {
"state": {
"very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
"bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
"sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
}
}
}
}
}
@@ -204,7 +204,9 @@ class GoogleGenerativeAIConversationEntity(
"""Process a sentence."""
result = conversation.ConversationResult(
response=intent.IntentResponse(language=user_input.language),
conversation_id=user_input.conversation_id or ulid.ulid_now(),
conversation_id=user_input.conversation_id
if user_input.conversation_id in self.history
else ulid.ulid_now(),
)
assert result.conversation_id
+10 -52
View File
@@ -1,27 +1,15 @@
"""The habitica integration."""
from http import HTTPStatus
from aiohttp import ClientResponseError
from habitipy.aio import HabitipyAsync
from habiticalib import Habitica
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
APPLICATION_NAME,
CONF_API_KEY,
CONF_NAME,
CONF_URL,
CONF_VERIFY_SSL,
Platform,
__version__,
)
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import 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 CONF_API_USER, DEVELOPER_ID, DOMAIN
from .const import CONF_API_USER, DOMAIN, X_CLIENT
from .coordinator import HabiticaDataUpdateCoordinator
from .services import async_setup_services
from .types import HabiticaConfigEntry
@@ -51,47 +39,17 @@ async def async_setup_entry(
) -> bool:
"""Set up habitica from a config entry."""
class HAHabitipyAsync(HabitipyAsync):
"""Closure API class to hold session."""
def __call__(self, **kwargs):
return super().__call__(websession, **kwargs)
def _make_headers(self) -> dict[str, str]:
headers = super()._make_headers()
headers.update(
{"x-client": f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"}
)
return headers
websession = async_get_clientsession(
session = async_get_clientsession(
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
)
api = await hass.async_add_executor_job(
HAHabitipyAsync,
{
"url": config_entry.data[CONF_URL],
"login": config_entry.data[CONF_API_USER],
"password": config_entry.data[CONF_API_KEY],
},
api = Habitica(
session,
api_user=config_entry.data[CONF_API_USER],
api_key=config_entry.data[CONF_API_KEY],
url=config_entry.data[CONF_URL],
x_client=X_CLIENT,
)
try:
user = await api.user.get(userFields="profile")
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
raise ConfigEntryNotReady(e) from e
if not config_entry.data.get(CONF_NAME):
name = user["profile"]["name"]
hass.config_entries.async_update_entry(
config_entry,
data={**config_entry.data, CONF_NAME: name},
)
coordinator = HabiticaDataUpdateCoordinator(hass, api)
await coordinator.async_config_entry_first_refresh()
@@ -5,7 +5,8 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from habiticalib import UserData
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
@@ -23,8 +24,8 @@ from .types import HabiticaConfigEntry
class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Habitica Binary Sensor Description."""
value_fn: Callable[[dict[str, Any]], bool | None]
entity_picture: Callable[[dict[str, Any]], str | None]
value_fn: Callable[[UserData], bool | None]
entity_picture: Callable[[UserData], str | None]
class HabiticaBinarySensor(StrEnum):
@@ -33,10 +34,10 @@ class HabiticaBinarySensor(StrEnum):
PENDING_QUEST = "pending_quest"
def get_scroll_image_for_pending_quest_invitation(user: dict[str, Any]) -> str | None:
def get_scroll_image_for_pending_quest_invitation(user: UserData) -> str | None:
"""Entity picture for pending quest invitation."""
if user["party"]["quest"].get("key") and user["party"]["quest"]["RSVPNeeded"]:
return f"inventory_quest_scroll_{user["party"]["quest"]["key"]}.png"
if user.party.quest.key and user.party.quest.RSVPNeeded:
return f"inventory_quest_scroll_{user.party.quest.key}.png"
return None
@@ -44,7 +45,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[HabiticaBinarySensorEntityDescription, ...] =
HabiticaBinarySensorEntityDescription(
key=HabiticaBinarySensor.PENDING_QUEST,
translation_key=HabiticaBinarySensor.PENDING_QUEST,
value_fn=lambda user: user["party"]["quest"]["RSVPNeeded"],
value_fn=lambda user: user.party.quest.RSVPNeeded,
entity_picture=get_scroll_image_for_pending_quest_invitation,
),
)
+103 -94
View File
@@ -5,10 +5,17 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from http import HTTPStatus
from typing import Any
from aiohttp import ClientResponseError
from aiohttp import ClientError
from habiticalib import (
HabiticaClass,
HabiticaException,
NotAuthorizedError,
Skill,
TaskType,
TooManyRequestsError,
)
from homeassistant.components.button import (
DOMAIN as BUTTON_DOMAIN,
@@ -20,7 +27,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ASSETS_URL, DOMAIN, HEALER, MAGE, ROGUE, WARRIOR
from .const import ASSETS_URL, DOMAIN
from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
@@ -34,7 +41,7 @@ class HabiticaButtonEntityDescription(ButtonEntityDescription):
press_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
available_fn: Callable[[HabiticaData], bool]
class_needed: str | None = None
class_needed: HabiticaClass | None = None
entity_picture: str | None = None
@@ -63,35 +70,33 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.RUN_CRON,
translation_key=HabitipyButtonEntity.RUN_CRON,
press_fn=lambda coordinator: coordinator.api.cron.post(),
available_fn=lambda data: data.user["needsCron"],
press_fn=lambda coordinator: coordinator.habitica.run_cron(),
available_fn=lambda data: data.user.needsCron is True,
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.BUY_HEALTH_POTION,
translation_key=HabitipyButtonEntity.BUY_HEALTH_POTION,
press_fn=(
lambda coordinator: coordinator.api["user"]["buy-health-potion"].post()
),
press_fn=lambda coordinator: coordinator.habitica.buy_health_potion(),
available_fn=(
lambda data: data.user["stats"]["gp"] >= 25
and data.user["stats"]["hp"] < 50
lambda data: (data.user.stats.gp or 0) >= 25
and (data.user.stats.hp or 0) < 50
),
entity_picture="shop_potion.png",
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS,
translation_key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS,
press_fn=lambda coordinator: coordinator.api["user"]["allocate-now"].post(),
press_fn=lambda coordinator: coordinator.habitica.allocate_stat_points(),
available_fn=(
lambda data: data.user["preferences"].get("automaticAllocation") is True
and data.user["stats"]["points"] > 0
lambda data: data.user.preferences.automaticAllocation is True
and (data.user.stats.points or 0) > 0
),
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.REVIVE,
translation_key=HabitipyButtonEntity.REVIVE,
press_fn=lambda coordinator: coordinator.api["user"]["revive"].post(),
available_fn=lambda data: data.user["stats"]["hp"] == 0,
press_fn=lambda coordinator: coordinator.habitica.revive(),
available_fn=lambda data: data.user.stats.hp == 0,
),
)
@@ -100,166 +105,170 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.MPHEAL,
translation_key=HabitipyButtonEntity.MPHEAL,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["mpheal"].post(),
available_fn=(
lambda data: data.user["stats"]["lvl"] >= 12
and data.user["stats"]["mp"] >= 30
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.ETHEREAL_SURGE)
),
class_needed=MAGE,
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 12
and (data.user.stats.mp or 0) >= 30
),
class_needed=HabiticaClass.MAGE,
entity_picture="shop_mpheal.png",
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.EARTH,
translation_key=HabitipyButtonEntity.EARTH,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["earth"].post(),
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.EARTHQUAKE),
available_fn=(
lambda data: data.user["stats"]["lvl"] >= 13
and data.user["stats"]["mp"] >= 35
lambda data: (data.user.stats.lvl or 0) >= 13
and (data.user.stats.mp or 0) >= 35
),
class_needed=MAGE,
class_needed=HabiticaClass.MAGE,
entity_picture="shop_earth.png",
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.FROST,
translation_key=HabitipyButtonEntity.FROST,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["frost"].post(),
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.CHILLING_FROST)
),
# chilling frost can only be cast once per day (streaks buff is false)
available_fn=(
lambda data: data.user["stats"]["lvl"] >= 14
and data.user["stats"]["mp"] >= 40
and not data.user["stats"]["buffs"]["streaks"]
lambda data: (data.user.stats.lvl or 0) >= 14
and (data.user.stats.mp or 0) >= 40
and not data.user.stats.buffs.streaks
),
class_needed=MAGE,
class_needed=HabiticaClass.MAGE,
entity_picture="shop_frost.png",
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.DEFENSIVE_STANCE,
translation_key=HabitipyButtonEntity.DEFENSIVE_STANCE,
press_fn=(
lambda coordinator: coordinator.api.user.class_.cast[
"defensiveStance"
].post()
lambda coordinator: coordinator.habitica.cast_skill(Skill.DEFENSIVE_STANCE)
),
available_fn=(
lambda data: data.user["stats"]["lvl"] >= 12
and data.user["stats"]["mp"] >= 25
lambda data: (data.user.stats.lvl or 0) >= 12
and (data.user.stats.mp or 0) >= 25
),
class_needed=WARRIOR,
class_needed=HabiticaClass.WARRIOR,
entity_picture="shop_defensiveStance.png",
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.VALOROUS_PRESENCE,
translation_key=HabitipyButtonEntity.VALOROUS_PRESENCE,
press_fn=(
lambda coordinator: coordinator.api.user.class_.cast[
"valorousPresence"
].post()
lambda coordinator: coordinator.habitica.cast_skill(Skill.VALOROUS_PRESENCE)
),
available_fn=(
lambda data: data.user["stats"]["lvl"] >= 13
and data.user["stats"]["mp"] >= 20
lambda data: (data.user.stats.lvl or 0) >= 13
and (data.user.stats.mp or 0) >= 20
),
class_needed=WARRIOR,
class_needed=HabiticaClass.WARRIOR,
entity_picture="shop_valorousPresence.png",
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.INTIMIDATE,
translation_key=HabitipyButtonEntity.INTIMIDATE,
press_fn=(
lambda coordinator: coordinator.api.user.class_.cast["intimidate"].post()
lambda coordinator: coordinator.habitica.cast_skill(Skill.INTIMIDATING_GAZE)
),
available_fn=(
lambda data: data.user["stats"]["lvl"] >= 14
and data.user["stats"]["mp"] >= 15
lambda data: (data.user.stats.lvl or 0) >= 14
and (data.user.stats.mp or 0) >= 15
),
class_needed=WARRIOR,
class_needed=HabiticaClass.WARRIOR,
entity_picture="shop_intimidate.png",
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.TOOLS_OF_TRADE,
translation_key=HabitipyButtonEntity.TOOLS_OF_TRADE,
press_fn=(
lambda coordinator: coordinator.api.user.class_.cast["toolsOfTrade"].post()
lambda coordinator: coordinator.habitica.cast_skill(
Skill.TOOLS_OF_THE_TRADE
)
),
available_fn=(
lambda data: data.user["stats"]["lvl"] >= 13
and data.user["stats"]["mp"] >= 25
lambda data: (data.user.stats.lvl or 0) >= 13
and (data.user.stats.mp or 0) >= 25
),
class_needed=ROGUE,
class_needed=HabiticaClass.ROGUE,
entity_picture="shop_toolsOfTrade.png",
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.STEALTH,
translation_key=HabitipyButtonEntity.STEALTH,
press_fn=(
lambda coordinator: coordinator.api.user.class_.cast["stealth"].post()
),
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.STEALTH),
# Stealth buffs stack and it can only be cast if the amount of
# unfinished dailies is smaller than the amount of buffs
# buffs is smaller than the amount of unfinished dailies
available_fn=(
lambda data: data.user["stats"]["lvl"] >= 14
and data.user["stats"]["mp"] >= 45
and data.user["stats"]["buffs"]["stealth"]
lambda data: (data.user.stats.lvl or 0) >= 14
and (data.user.stats.mp or 0) >= 45
and (data.user.stats.buffs.stealth or 0)
< len(
[
r
for r in data.tasks
if r.get("type") == "daily"
and r.get("isDue") is True
and r.get("completed") is False
if r.Type is TaskType.DAILY
and r.isDue is True
and r.completed is False
]
)
),
class_needed=ROGUE,
class_needed=HabiticaClass.ROGUE,
entity_picture="shop_stealth.png",
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.HEAL,
translation_key=HabitipyButtonEntity.HEAL,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["heal"].post(),
available_fn=(
lambda data: data.user["stats"]["lvl"] >= 11
and data.user["stats"]["mp"] >= 15
and data.user["stats"]["hp"] < 50
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.HEALING_LIGHT)
),
class_needed=HEALER,
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 11
and (data.user.stats.mp or 0) >= 15
and (data.user.stats.hp or 0) < 50
),
class_needed=HabiticaClass.HEALER,
entity_picture="shop_heal.png",
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.BRIGHTNESS,
translation_key=HabitipyButtonEntity.BRIGHTNESS,
press_fn=(
lambda coordinator: coordinator.api.user.class_.cast["brightness"].post()
lambda coordinator: coordinator.habitica.cast_skill(
Skill.SEARING_BRIGHTNESS
)
),
available_fn=(
lambda data: data.user["stats"]["lvl"] >= 12
and data.user["stats"]["mp"] >= 15
lambda data: (data.user.stats.lvl or 0) >= 12
and (data.user.stats.mp or 0) >= 15
),
class_needed=HEALER,
class_needed=HabiticaClass.HEALER,
entity_picture="shop_brightness.png",
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.PROTECT_AURA,
translation_key=HabitipyButtonEntity.PROTECT_AURA,
press_fn=(
lambda coordinator: coordinator.api.user.class_.cast["protectAura"].post()
lambda coordinator: coordinator.habitica.cast_skill(Skill.PROTECTIVE_AURA)
),
available_fn=(
lambda data: data.user["stats"]["lvl"] >= 13
and data.user["stats"]["mp"] >= 30
lambda data: (data.user.stats.lvl or 0) >= 13
and (data.user.stats.mp or 0) >= 30
),
class_needed=HEALER,
class_needed=HabiticaClass.HEALER,
entity_picture="shop_protectAura.png",
),
HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.HEAL_ALL,
translation_key=HabitipyButtonEntity.HEAL_ALL,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["healAll"].post(),
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.BLESSING),
available_fn=(
lambda data: data.user["stats"]["lvl"] >= 14
and data.user["stats"]["mp"] >= 25
lambda data: (data.user.stats.lvl or 0) >= 14
and (data.user.stats.mp or 0) >= 25
),
class_needed=HEALER,
class_needed=HabiticaClass.HEALER,
entity_picture="shop_healAll.png",
),
)
@@ -285,10 +294,10 @@ async def async_setup_entry(
for description in CLASS_SKILLS:
if (
coordinator.data.user["stats"]["lvl"] >= 10
and coordinator.data.user["flags"]["classSelected"]
and not coordinator.data.user["preferences"]["disableClasses"]
and description.class_needed == coordinator.data.user["stats"]["class"]
(coordinator.data.user.stats.lvl or 0) >= 10
and coordinator.data.user.flags.classSelected
and not coordinator.data.user.preferences.disableClasses
and description.class_needed is coordinator.data.user.stats.Class
):
if description.key not in skills_added:
buttons.append(HabiticaButton(coordinator, description))
@@ -322,17 +331,17 @@ class HabiticaButton(HabiticaBase, ButtonEntity):
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator)
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.UNAUTHORIZED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="service_call_unallowed",
) from e
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
except NotAuthorizedError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="service_call_unallowed",
) from e
except (HabiticaException, ClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
+46 -39
View File
@@ -5,8 +5,11 @@ from __future__ import annotations
from abc import abstractmethod
from datetime import date, datetime, timedelta
from enum import StrEnum
from typing import TYPE_CHECKING
from uuid import UUID
from dateutil.rrule import rrule
from habiticalib import TaskType
from homeassistant.components.calendar import (
CalendarEntity,
@@ -20,7 +23,6 @@ from homeassistant.util import dt as dt_util
from . import HabiticaConfigEntry
from .coordinator import HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
from .types import HabiticaTaskType
from .util import build_rrule, get_recurrence_rule
@@ -83,9 +85,7 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity):
@property
def start_of_today(self) -> datetime:
"""Habitica daystart."""
return dt_util.start_of_local_day(
datetime.fromisoformat(self.coordinator.data.user["lastCron"])
)
return dt_util.start_of_local_day(self.coordinator.data.user.lastCron)
def get_recurrence_dates(
self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None
@@ -115,13 +115,13 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
events = []
for task in self.coordinator.data.tasks:
if not (
task["type"] == HabiticaTaskType.TODO
and not task["completed"]
and task.get("date") # only if has due date
task.Type is TaskType.TODO
and not task.completed
and task.date is not None # only if has due date
):
continue
start = dt_util.start_of_local_day(datetime.fromisoformat(task["date"]))
start = dt_util.start_of_local_day(task.date)
end = start + timedelta(days=1)
# return current and upcoming events or events within the requested range
@@ -132,21 +132,23 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
if end_date and start > end_date:
# Event starts after date range
continue
if TYPE_CHECKING:
assert task.text
assert task.id
events.append(
CalendarEvent(
start=start.date(),
end=end.date(),
summary=task["text"],
description=task["notes"],
uid=task["id"],
summary=task.text,
description=task.notes,
uid=str(task.id),
)
)
return sorted(
events,
key=lambda event: (
event.start,
self.coordinator.data.user["tasksOrder"]["todos"].index(event.uid),
self.coordinator.data.user.tasksOrder.todos.index(UUID(event.uid)),
),
)
@@ -189,7 +191,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
events = []
for task in self.coordinator.data.tasks:
# only dailies that that are not 'grey dailies'
if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
if not (task.Type is TaskType.DAILY and task.everyX):
continue
recurrences = build_rrule(task)
@@ -199,19 +201,21 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
for recurrence in recurrence_dates:
is_future_event = recurrence > self.start_of_today
is_current_event = (
recurrence <= self.start_of_today and not task["completed"]
recurrence <= self.start_of_today and not task.completed
)
if not is_future_event and not is_current_event:
continue
if TYPE_CHECKING:
assert task.text
assert task.id
events.append(
CalendarEvent(
start=recurrence.date(),
end=self.end_date(recurrence, end_date),
summary=task["text"],
description=task["notes"],
uid=task["id"],
summary=task.text,
description=task.notes,
uid=str(task.id),
rrule=get_recurrence_rule(recurrences),
)
)
@@ -219,7 +223,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
events,
key=lambda event: (
event.start,
self.coordinator.data.user["tasksOrder"]["dailys"].index(event.uid),
self.coordinator.data.user.tasksOrder.dailys.index(UUID(event.uid)),
),
)
@@ -254,14 +258,14 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
events = []
for task in self.coordinator.data.tasks:
if task["type"] != HabiticaTaskType.TODO or task["completed"]:
if task.Type is not TaskType.TODO or task.completed:
continue
for reminder in task.get("reminders", []):
for reminder in task.reminders:
# reminders are returned by the API in local time but with wrong
# timezone (UTC) and arbitrary added seconds/microseconds. When
# creating reminders in Habitica only hours and minutes can be defined.
start = datetime.fromisoformat(reminder["time"]).replace(
start = reminder.time.replace(
tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0
)
end = start + timedelta(hours=1)
@@ -273,14 +277,16 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
if end_date and start > end_date:
# Event starts after date range
continue
if TYPE_CHECKING:
assert task.text
assert task.id
events.append(
CalendarEvent(
start=start,
end=end,
summary=task["text"],
description=task["notes"],
uid=f"{task["id"]}_{reminder["id"]}",
summary=task.text,
description=task.notes,
uid=f"{task.id}_{reminder.id}",
)
)
@@ -298,7 +304,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
translation_key=HabiticaCalendar.DAILY_REMINDERS,
)
def start(self, reminder_time: str, reminder_date: date) -> datetime:
def start(self, reminder_time: datetime, reminder_date: date) -> datetime:
"""Generate reminder times for dailies.
Reminders for dailies have a datetime but the date part is arbitrary,
@@ -307,12 +313,10 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
"""
return datetime.combine(
reminder_date,
datetime.fromisoformat(reminder_time)
.replace(
reminder_time.replace(
second=0,
microsecond=0,
)
.time(),
).time(),
tzinfo=dt_util.DEFAULT_TIME_ZONE,
)
@@ -327,7 +331,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
start_date = max(start_date, self.start_of_today)
for task in self.coordinator.data.tasks:
if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
if not (task.Type is TaskType.DAILY and task.everyX):
continue
recurrences = build_rrule(task)
@@ -339,27 +343,30 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
for recurrence in recurrence_dates:
is_future_event = recurrence > self.start_of_today
is_current_event = (
recurrence <= self.start_of_today and not task["completed"]
recurrence <= self.start_of_today and not task.completed
)
if not is_future_event and not is_current_event:
continue
for reminder in task.get("reminders", []):
start = self.start(reminder["time"], recurrence)
for reminder in task.reminders:
start = self.start(reminder.time, recurrence)
end = start + timedelta(hours=1)
if end < start_date:
# Event ends before date range
continue
if TYPE_CHECKING:
assert task.id
assert task.text
events.append(
CalendarEvent(
start=start,
end=end,
summary=task["text"],
description=task["notes"],
uid=f"{task["id"]}_{reminder["id"]}",
summary=task.text,
description=task.notes,
uid=f"{task.id}_{reminder.id}",
)
)
+188 -65
View File
@@ -2,17 +2,25 @@
from __future__ import annotations
from http import HTTPStatus
from collections.abc import Mapping
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from aiohttp import ClientResponseError
from habitipy.aio import HabitipyAsync
from aiohttp import ClientError
from habiticalib import (
Habitica,
HabiticaException,
LoginData,
NotAuthorizedError,
UserData,
)
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_API_KEY,
CONF_NAME,
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
@@ -25,14 +33,18 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
from . import HabiticaConfigEntry
from .const import (
CONF_API_USER,
DEFAULT_URL,
DOMAIN,
FORGOT_PASSWORD_URL,
HABITICANS_URL,
SECTION_REAUTH_API_KEY,
SECTION_REAUTH_LOGIN,
SIGN_UP_URL,
SITE_DATA_URL,
X_CLIENT,
)
STEP_ADVANCED_DATA_SCHEMA = vol.Schema(
@@ -61,14 +73,44 @@ STEP_LOGIN_DATA_SCHEMA = vol.Schema(
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(SECTION_REAUTH_LOGIN): data_entry_flow.section(
vol.Schema(
{
vol.Optional(CONF_USERNAME): TextSelector(
TextSelectorConfig(
type=TextSelectorType.EMAIL,
autocomplete="email",
)
),
vol.Optional(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
},
),
{"collapsed": False},
),
vol.Required(SECTION_REAUTH_API_KEY): data_entry_flow.section(
vol.Schema(
{
vol.Optional(CONF_API_KEY): str,
},
),
{"collapsed": True},
),
}
)
_LOGGER = logging.getLogger(__name__)
class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for habitica."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -93,39 +135,20 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
"""
errors: dict[str, str] = {}
if user_input is not None:
try:
session = async_get_clientsession(self.hass)
api = await self.hass.async_add_executor_job(
HabitipyAsync,
{
"login": "",
"password": "",
"url": DEFAULT_URL,
},
)
login_response = await api.user.auth.local.login.post(
session=session,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
except ClientResponseError as ex:
if ex.status == HTTPStatus.UNAUTHORIZED:
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(login_response["id"])
errors, login, user = await self.validate_login(
{**user_input, CONF_URL: DEFAULT_URL}
)
if not errors and login is not None and user is not None:
await self.async_set_unique_id(str(login.id))
self._abort_if_unique_id_configured()
if TYPE_CHECKING:
assert user.profile.name
return self.async_create_entry(
title=login_response["username"],
title=user.profile.name,
data={
CONF_API_USER: login_response["id"],
CONF_API_KEY: login_response["apiToken"],
CONF_USERNAME: login_response["username"],
CONF_API_USER: str(login.id),
CONF_API_KEY: login.apiToken,
CONF_NAME: user.profile.name, # needed for api_call action
CONF_URL: DEFAULT_URL,
CONF_VERIFY_SSL: True,
},
@@ -150,36 +173,19 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
"""
errors: dict[str, str] = {}
if user_input is not None:
try:
session = async_get_clientsession(
self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
)
api = await self.hass.async_add_executor_job(
HabitipyAsync,
{
"login": user_input[CONF_API_USER],
"password": user_input[CONF_API_KEY],
"url": user_input.get(CONF_URL, DEFAULT_URL),
},
)
api_response = await api.user.get(
session=session,
userFields="auth",
)
except ClientResponseError as ex:
if ex.status == HTTPStatus.UNAUTHORIZED:
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_API_USER])
self._abort_if_unique_id_configured()
user_input[CONF_USERNAME] = api_response["auth"]["local"]["username"]
await self.async_set_unique_id(user_input[CONF_API_USER])
self._abort_if_unique_id_configured()
errors, user = await self.validate_api_key(user_input)
if not errors and user is not None:
if TYPE_CHECKING:
assert user.profile.name
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
title=user.profile.name,
data={
**user_input,
CONF_URL: user_input.get(CONF_URL, DEFAULT_URL),
CONF_NAME: user.profile.name, # needed for api_call action
},
)
return self.async_show_form(
@@ -193,3 +199,120 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
"default_url": DEFAULT_URL,
},
)
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: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
errors: dict[str, str] = {}
reauth_entry: HabiticaConfigEntry = self._get_reauth_entry()
if user_input is not None:
if user_input[SECTION_REAUTH_LOGIN].get(CONF_USERNAME) and user_input[
SECTION_REAUTH_LOGIN
].get(CONF_PASSWORD):
errors, login, _ = await self.validate_login(
{**reauth_entry.data, **user_input[SECTION_REAUTH_LOGIN]}
)
if not errors and login is not None:
await self.async_set_unique_id(str(login.id))
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_API_KEY: login.apiToken},
)
elif user_input[SECTION_REAUTH_API_KEY].get(CONF_API_KEY):
errors, user = await self.validate_api_key(
{
**reauth_entry.data,
**user_input[SECTION_REAUTH_API_KEY],
}
)
if not errors and user is not None:
return self.async_update_reload_and_abort(
reauth_entry, data_updates=user_input[SECTION_REAUTH_API_KEY]
)
else:
errors["base"] = "invalid_credentials"
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_REAUTH_DATA_SCHEMA,
suggested_values={
CONF_USERNAME: (
user_input[SECTION_REAUTH_LOGIN].get(CONF_USERNAME)
if user_input
else None,
)
},
),
description_placeholders={
CONF_NAME: reauth_entry.title,
"habiticans": HABITICANS_URL,
},
errors=errors,
)
async def validate_login(
self, user_input: Mapping[str, Any]
) -> tuple[dict[str, str], LoginData | None, UserData | None]:
"""Validate login with login credentials."""
errors: dict[str, str] = {}
session = async_get_clientsession(
self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
)
api = Habitica(session=session, x_client=X_CLIENT)
try:
login = await api.login(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
user = await api.get_user(user_fields="profile")
except NotAuthorizedError:
errors["base"] = "invalid_auth"
except (HabiticaException, ClientError):
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return errors, login.data, user.data
return errors, None, None
async def validate_api_key(
self, user_input: Mapping[str, Any]
) -> tuple[dict[str, str], UserData | None]:
"""Validate authentication with api key."""
errors: dict[str, str] = {}
session = async_get_clientsession(
self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
)
api = Habitica(
session=session,
x_client=X_CLIENT,
api_user=user_input[CONF_API_USER],
api_key=user_input[CONF_API_KEY],
url=user_input.get(CONF_URL, DEFAULT_URL),
)
try:
user = await api.get_user(user_fields="profile")
except NotAuthorizedError:
errors["base"] = "invalid_auth"
except (HabiticaException, ClientError):
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return errors, user.data
return errors, None
+5 -6
View File
@@ -1,6 +1,6 @@
"""Constants for the habitica integration."""
from homeassistant.const import CONF_PATH
from homeassistant.const import APPLICATION_NAME, CONF_PATH, __version__
CONF_API_USER = "api_user"
@@ -44,9 +44,8 @@ SERVICE_SCORE_REWARD = "score_reward"
SERVICE_TRANSFORMATION = "transformation"
WARRIOR = "warrior"
ROGUE = "rogue"
HEALER = "healer"
MAGE = "wizard"
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"
SECTION_REAUTH_LOGIN = "reauth_login"
SECTION_REAUTH_API_KEY = "reauth_api_key"
@@ -5,16 +5,29 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from http import HTTPStatus
import logging
from typing import Any
from aiohttp import ClientResponseError
from habitipy.aio import HabitipyAsync
from aiohttp import ClientError
from habiticalib import (
ContentData,
Habitica,
HabiticaException,
NotAuthorizedError,
TaskData,
TaskFilter,
TooManyRequestsError,
UserData,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -25,10 +38,10 @@ _LOGGER = logging.getLogger(__name__)
@dataclass
class HabiticaData:
"""Coordinator data class."""
"""Habitica data."""
user: dict[str, Any]
tasks: list[dict]
user: UserData
tasks: list[TaskData]
class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
@@ -36,7 +49,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, habitipy: HabitipyAsync) -> None:
def __init__(self, hass: HomeAssistant, habitica: Habitica) -> None:
"""Initialize the Habitica data coordinator."""
super().__init__(
hass,
@@ -50,25 +63,53 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
immediate=False,
),
)
self.api = habitipy
self.content: dict[str, Any] = {}
self.habitica = habitica
self.content: ContentData
async def _async_setup(self) -> None:
"""Set up Habitica integration."""
try:
user = await self.habitica.get_user()
self.content = (
await self.habitica.get_content(user.data.preferences.language)
).data
except NotAuthorizedError as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_failed",
) from e
except TooManyRequestsError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
except (HabiticaException, ClientError) as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
if not self.config_entry.data.get(CONF_NAME):
self.hass.config_entries.async_update_entry(
self.config_entry,
data={**self.config_entry.data, CONF_NAME: user.data.profile.name},
)
async def _async_update_data(self) -> HabiticaData:
try:
user_response = await self.api.user.get()
tasks_response = await self.api.tasks.user.get()
tasks_response.extend(await self.api.tasks.user.get(type="completedTodos"))
if not self.content:
self.content = await self.api.content.get(
language=user_response["preferences"]["language"]
)
except ClientResponseError as error:
if error.status == HTTPStatus.TOO_MANY_REQUESTS:
_LOGGER.debug("Rate limit exceeded, will try again later")
return self.data
raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error
return HabiticaData(user=user_response, tasks=tasks_response)
user = (await self.habitica.get_user()).data
tasks = (await self.habitica.get_tasks()).data
completed_todos = (
await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS)
).data
except TooManyRequestsError:
_LOGGER.debug("Rate limit exceeded, will try again later")
return self.data
except (HabiticaException, ClientError) as e:
raise UpdateFailed(f"Unable to connect to Habitica: {e}") from e
else:
return HabiticaData(user=user, tasks=tasks + completed_todos)
async def execute(
self, func: Callable[[HabiticaDataUpdateCoordinator], Any]
@@ -77,12 +118,12 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
try:
await func(self)
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
except (HabiticaException, ClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
@@ -16,12 +16,12 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
habitica_data = await config_entry.runtime_data.api.user.anonymized.get()
habitica_data = await config_entry.runtime_data.habitica.get_user_anonymized()
return {
"config_entry_data": {
CONF_URL: config_entry.data[CONF_URL],
CONF_API_USER: config_entry.data[CONF_API_USER],
},
"habitica_data": habitica_data,
"habitica_data": habitica_data.to_dict()["data"],
}
@@ -5,6 +5,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling",
"loggers": ["habitipy", "plumbum"],
"requirements": ["habitipy==0.3.3"]
"loggers": ["habiticalib"],
"requirements": ["habiticalib==0.3.2"]
}
@@ -34,7 +34,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold
+44 -39
View File
@@ -3,11 +3,20 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from dataclasses import asdict, dataclass
from enum import StrEnum
import logging
from typing import TYPE_CHECKING, Any
from habiticalib import (
ContentData,
HabiticaClass,
TaskData,
TaskType,
UserData,
deserialize_task,
)
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
@@ -36,10 +45,10 @@ _LOGGER = logging.getLogger(__name__)
class HabitipySensorEntityDescription(SensorEntityDescription):
"""Habitipy Sensor Description."""
value_fn: Callable[[dict[str, Any], dict[str, Any]], StateType]
attributes_fn: (
Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None] | None
) = None
value_fn: Callable[[UserData, ContentData], StateType]
attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = (
None
)
entity_picture: str | None = None
@@ -47,7 +56,7 @@ class HabitipySensorEntityDescription(SensorEntityDescription):
class HabitipyTaskSensorEntityDescription(SensorEntityDescription):
"""Habitipy Task Sensor Description."""
value_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]]
value_fn: Callable[[list[TaskData]], list[TaskData]]
class HabitipySensorEntity(StrEnum):
@@ -79,75 +88,70 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
HabitipySensorEntityDescription(
key=HabitipySensorEntity.DISPLAY_NAME,
translation_key=HabitipySensorEntity.DISPLAY_NAME,
value_fn=lambda user, _: user.get("profile", {}).get("name"),
value_fn=lambda user, _: user.profile.name,
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.HEALTH,
translation_key=HabitipySensorEntity.HEALTH,
suggested_display_precision=0,
value_fn=lambda user, _: user.get("stats", {}).get("hp"),
value_fn=lambda user, _: user.stats.hp,
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.HEALTH_MAX,
translation_key=HabitipySensorEntity.HEALTH_MAX,
entity_registry_enabled_default=False,
value_fn=lambda user, _: user.get("stats", {}).get("maxHealth"),
value_fn=lambda user, _: 50,
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.MANA,
translation_key=HabitipySensorEntity.MANA,
suggested_display_precision=0,
value_fn=lambda user, _: user.get("stats", {}).get("mp"),
value_fn=lambda user, _: user.stats.mp,
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.MANA_MAX,
translation_key=HabitipySensorEntity.MANA_MAX,
value_fn=lambda user, _: user.get("stats", {}).get("maxMP"),
value_fn=lambda user, _: user.stats.maxMP,
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.EXPERIENCE,
translation_key=HabitipySensorEntity.EXPERIENCE,
value_fn=lambda user, _: user.get("stats", {}).get("exp"),
value_fn=lambda user, _: user.stats.exp,
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.EXPERIENCE_MAX,
translation_key=HabitipySensorEntity.EXPERIENCE_MAX,
value_fn=lambda user, _: user.get("stats", {}).get("toNextLevel"),
value_fn=lambda user, _: user.stats.toNextLevel,
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.LEVEL,
translation_key=HabitipySensorEntity.LEVEL,
value_fn=lambda user, _: user.get("stats", {}).get("lvl"),
value_fn=lambda user, _: user.stats.lvl,
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.GOLD,
translation_key=HabitipySensorEntity.GOLD,
suggested_display_precision=2,
value_fn=lambda user, _: user.get("stats", {}).get("gp"),
value_fn=lambda user, _: user.stats.gp,
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.CLASS,
translation_key=HabitipySensorEntity.CLASS,
value_fn=lambda user, _: user.get("stats", {}).get("class"),
value_fn=lambda user, _: user.stats.Class.value if user.stats.Class else None,
device_class=SensorDeviceClass.ENUM,
options=["warrior", "healer", "wizard", "rogue"],
options=[item.value for item in HabiticaClass],
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.GEMS,
translation_key=HabitipySensorEntity.GEMS,
value_fn=lambda user, _: user.get("balance", 0) * 4,
value_fn=lambda user, _: round(user.balance * 4) if user.balance else None,
suggested_display_precision=0,
entity_picture="shop_gem.png",
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.TRINKETS,
translation_key=HabitipySensorEntity.TRINKETS,
value_fn=(
lambda user, _: user.get("purchased", {})
.get("plan", {})
.get("consecutive", {})
.get("trinkets", 0)
),
value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets or 0,
suggested_display_precision=0,
native_unit_of_measurement="",
entity_picture="notif_subscriber_reward.png",
@@ -155,16 +159,16 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
HabitipySensorEntityDescription(
key=HabitipySensorEntity.STRENGTH,
translation_key=HabitipySensorEntity.STRENGTH,
value_fn=lambda user, content: get_attributes_total(user, content, "str"),
attributes_fn=lambda user, content: get_attribute_points(user, content, "str"),
value_fn=lambda user, content: get_attributes_total(user, content, "Str"),
attributes_fn=lambda user, content: get_attribute_points(user, content, "Str"),
suggested_display_precision=0,
native_unit_of_measurement="STR",
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.INTELLIGENCE,
translation_key=HabitipySensorEntity.INTELLIGENCE,
value_fn=lambda user, content: get_attributes_total(user, content, "int"),
attributes_fn=lambda user, content: get_attribute_points(user, content, "int"),
value_fn=lambda user, content: get_attributes_total(user, content, "Int"),
attributes_fn=lambda user, content: get_attribute_points(user, content, "Int"),
suggested_display_precision=0,
native_unit_of_measurement="INT",
),
@@ -203,7 +207,7 @@ TASKS_MAP = {
"yester_daily": "yesterDaily",
"completed": "completed",
"collapse_checklist": "collapseChecklist",
"type": "type",
"type": "Type",
"notes": "notes",
"tags": "tags",
"value": "value",
@@ -221,26 +225,28 @@ TASK_SENSOR_DESCRIPTION: tuple[HabitipyTaskSensorEntityDescription, ...] = (
HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.HABITS,
translation_key=HabitipySensorEntity.HABITS,
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "habit"],
value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.HABIT],
),
HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.DAILIES,
translation_key=HabitipySensorEntity.DAILIES,
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "daily"],
value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.DAILY],
entity_registry_enabled_default=False,
),
HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.TODOS,
translation_key=HabitipySensorEntity.TODOS,
value_fn=lambda tasks: [
r for r in tasks if r.get("type") == "todo" and not r.get("completed")
],
value_fn=(
lambda tasks: [
r for r in tasks if r.Type is TaskType.TODO and not r.completed
]
),
entity_registry_enabled_default=False,
),
HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.REWARDS,
translation_key=HabitipySensorEntity.REWARDS,
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "reward"],
value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.REWARD],
),
)
@@ -309,15 +315,14 @@ class HabitipyTaskSensor(HabiticaBase, SensorEntity):
attrs = {}
# Map tasks to TASKS_MAP
for received_task in self.entity_description.value_fn(
self.coordinator.data.tasks
):
for task_data in self.entity_description.value_fn(self.coordinator.data.tasks):
received_task = deserialize_task(asdict(task_data))
task_id = received_task[TASKS_MAP_ID]
task = {}
for map_key, map_value in TASKS_MAP.items():
if value := received_task.get(map_value):
task[map_key] = value
attrs[task_id] = task
attrs[str(task_id)] = task
return attrs
async def async_added_to_hass(self) -> None:
+149 -128
View File
@@ -2,11 +2,19 @@
from __future__ import annotations
from http import HTTPStatus
from dataclasses import asdict
import logging
from typing import Any
from typing import TYPE_CHECKING
from aiohttp import ClientResponseError
from aiohttp import ClientError
from habiticalib import (
Direction,
HabiticaException,
NotAuthorizedError,
NotFoundError,
Skill,
TooManyRequestsError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
@@ -88,6 +96,25 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
}
)
SKILL_MAP = {
"pickpocket": Skill.PICKPOCKET,
"backstab": Skill.BACKSTAB,
"smash": Skill.BRUTAL_SMASH,
"fireball": Skill.BURST_OF_FLAMES,
}
COST_MAP = {
"pickpocket": "10 MP",
"backstab": "15 MP",
"smash": "10 MP",
"fireball": "10 MP",
}
ITEMID_MAP = {
"snowball": Skill.SNOWBALL,
"spooky_sparkles": Skill.SPOOKY_SPARKLES,
"seafoam": Skill.SEAFOAM,
"shiny_seed": Skill.SHINY_SEED,
}
def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
"""Return config entry or raise if not found or not loaded."""
@@ -123,12 +150,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
name = call.data[ATTR_NAME]
path = call.data[ATTR_PATH]
entries = hass.config_entries.async_entries(DOMAIN)
entries: list[HabiticaConfigEntry] = hass.config_entries.async_entries(DOMAIN)
api = None
for entry in entries:
if entry.data[CONF_NAME] == name:
api = entry.runtime_data.api
api = await entry.runtime_data.habitica.habitipy()
break
if api is None:
_LOGGER.error("API_CALL: User '%s' not configured", name)
@@ -151,18 +178,15 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
"""Skill action."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
skill = {
"pickpocket": {"spellId": "pickPocket", "cost": "10 MP"},
"backstab": {"spellId": "backStab", "cost": "15 MP"},
"smash": {"spellId": "smash", "cost": "10 MP"},
"fireball": {"spellId": "fireball", "cost": "10 MP"},
}
skill = SKILL_MAP[call.data[ATTR_SKILL]]
cost = COST_MAP[call.data[ATTR_SKILL]]
try:
task_id = next(
task["id"]
task.id
for task in coordinator.data.tasks
if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
or call.data[ATTR_TASK] == task["text"]
if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
)
except StopIteration as e:
raise ServiceValidationError(
@@ -172,75 +196,76 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
) from e
try:
response: dict[str, Any] = await coordinator.api.user.class_.cast[
skill[call.data[ATTR_SKILL]]["spellId"]
].post(targetId=task_id)
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.UNAUTHORIZED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_enough_mana",
translation_placeholders={
"cost": skill[call.data[ATTR_SKILL]]["cost"],
"mana": f"{int(coordinator.data.user.get("stats", {}).get("mp", 0))} MP",
},
) from e
if e.status == HTTPStatus.NOT_FOUND:
# could also be task not found, but the task is looked up
# before the request, so most likely wrong skill selected
# or the skill hasn't been unlocked yet.
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="skill_not_found",
translation_placeholders={"skill": call.data[ATTR_SKILL]},
) from e
response = await coordinator.habitica.cast_skill(skill, task_id)
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
except NotAuthorizedError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_enough_mana",
translation_placeholders={
"cost": cost,
"mana": f"{int(coordinator.data.user.stats.mp or 0)} MP",
},
) from e
except NotFoundError as e:
# could also be task not found, but the task is looked up
# before the request, so most likely wrong skill selected
# or the skill hasn't been unlocked yet.
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="skill_not_found",
translation_placeholders={"skill": call.data[ATTR_SKILL]},
) from e
except (HabiticaException, ClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
else:
await coordinator.async_request_refresh()
return response
return asdict(response.data)
async def manage_quests(call: ServiceCall) -> ServiceResponse:
"""Accept, reject, start, leave or cancel quests."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
COMMAND_MAP = {
SERVICE_ABORT_QUEST: "abort",
SERVICE_ACCEPT_QUEST: "accept",
SERVICE_CANCEL_QUEST: "cancel",
SERVICE_LEAVE_QUEST: "leave",
SERVICE_REJECT_QUEST: "reject",
SERVICE_START_QUEST: "force-start",
FUNC_MAP = {
SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest,
SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest,
SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest,
SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest,
SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest,
SERVICE_START_QUEST: coordinator.habitica.start_quest,
}
func = FUNC_MAP[call.service]
try:
return await coordinator.api.groups.party.quests[
COMMAND_MAP[call.service]
].post()
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.UNAUTHORIZED:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="quest_action_unallowed"
) from e
if e.status == HTTPStatus.NOT_FOUND:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="quest_not_found"
) from e
response = await func()
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
except NotAuthorizedError as e:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="quest_action_unallowed"
) from e
except NotFoundError as e:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="quest_not_found"
) from e
except (HabiticaException, ClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="service_call_exception"
) from e
else:
return asdict(response.data)
for service in (
SERVICE_ABORT_QUEST,
@@ -262,12 +287,15 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
"""Score a task action."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
direction = (
Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP
)
try:
task_id, task_value = next(
(task["id"], task.get("value"))
(task.id, task.value)
for task in coordinator.data.tasks
if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
or call.data[ATTR_TASK] == task["text"]
if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
)
except StopIteration as e:
raise ServiceValidationError(
@@ -276,81 +304,76 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
) from e
if TYPE_CHECKING:
assert task_id
try:
response: dict[str, Any] = (
await coordinator.api.tasks[task_id]
.score[call.data.get(ATTR_DIRECTION, "up")]
.post()
)
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.UNAUTHORIZED and task_value is not None:
response = await coordinator.habitica.update_score(task_id, direction)
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
except NotAuthorizedError as e:
if task_value is not None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_enough_gold",
translation_placeholders={
"gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP",
"cost": f"{task_value} GP",
"gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP",
"cost": f"{task_value:.2f} GP",
},
) from e
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
except (HabiticaException, ClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
else:
await coordinator.async_request_refresh()
return response
return asdict(response.data)
async def transformation(call: ServiceCall) -> ServiceResponse:
"""User a transformation item on a player character."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
ITEMID_MAP = {
"snowball": {"itemId": "snowball"},
"spooky_sparkles": {"itemId": "spookySparkles"},
"seafoam": {"itemId": "seafoam"},
"shiny_seed": {"itemId": "shinySeed"},
}
item = ITEMID_MAP[call.data[ATTR_ITEM]]
# check if target is self
if call.data[ATTR_TARGET] in (
coordinator.data.user["id"],
coordinator.data.user["profile"]["name"],
coordinator.data.user["auth"]["local"]["username"],
str(coordinator.data.user.id),
coordinator.data.user.profile.name,
coordinator.data.user.auth.local.username,
):
target_id = coordinator.data.user["id"]
target_id = coordinator.data.user.id
else:
# check if target is a party member
try:
party = await coordinator.api.groups.party.members.get()
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.NOT_FOUND:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="party_not_found",
) from e
party = await coordinator.habitica.get_group_members(public_fields=True)
except NotFoundError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="party_not_found",
) from e
except (ClientError, HabiticaException) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
try:
target_id = next(
member["id"]
for member in party
if call.data[ATTR_TARGET].lower()
member.id
for member in party.data
if member.id
and call.data[ATTR_TARGET].lower()
in (
member["id"],
member["auth"]["local"]["username"].lower(),
member["profile"]["name"].lower(),
str(member.id),
str(member.auth.local.username).lower(),
str(member.profile.name).lower(),
)
)
except StopIteration as e:
@@ -360,27 +383,25 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"},
) from e
try:
response: dict[str, Any] = await coordinator.api.user.class_.cast[
ITEMID_MAP[call.data[ATTR_ITEM]]["itemId"]
].post(targetId=target_id)
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.UNAUTHORIZED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="item_not_found",
translation_placeholders={"item": call.data[ATTR_ITEM]},
) from e
response = await coordinator.habitica.cast_skill(item, target_id)
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
except NotAuthorizedError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="item_not_found",
translation_placeholders={"item": call.data[ATTR_ITEM]},
) from e
except (HabiticaException, ClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
else:
return response
return asdict(response.data)
hass.services.async_register(
DOMAIN,
+38 -3
View File
@@ -10,12 +10,15 @@
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"unique_id_mismatch": "Hmm, those login details are correct, but they're not for this adventurer. Got another account to try?",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
"unknown": "[%key:common::config_flow::error::unknown%]",
"invalid_credentials": "Input is incomplete. You must provide either your login details or an API token"
},
"step": {
"user": {
@@ -49,9 +52,38 @@
"data_description": {
"url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`",
"api_user": "User ID of your Habitica account",
"api_key": "API Token of the Habitica account"
"api_key": "API Token of the Habitica account",
"verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a Habitica instance using a self-signed certificate"
},
"description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to"
},
"reauth_confirm": {
"title": "Re-authorize {name} with Habitica",
"description": "![Habiticans]({habiticans}) It seems your API token for **{name}** has been reset. To re-authorize the integration, you can either log in with your username or email, and password, or directly provide your new API token.",
"sections": {
"reauth_login": {
"name": "Re-authorize via login",
"description": "Enter your login details below to re-authorize the Home Assistant integration with Habitica",
"data": {
"username": "[%key:component::habitica::config::step::login::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "[%key:component::habitica::config::step::login::data_description::username%]",
"password": "[%key:component::habitica::config::step::login::data_description::password%]"
}
},
"reauth_api_key": {
"description": "Enter your new API token below. You can find it in Habitica under 'Settings -> Site Data'",
"name": "Re-authorize via API Token",
"data": {
"api_key": "[%key:component::habitica::config::step::advanced::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]"
}
}
}
}
}
},
@@ -365,6 +397,9 @@
},
"item_not_found": {
"message": "Unable to use {item}, you don't own this item."
},
"authentication_failed": {
"message": "Authentication failed. It looks like your API token has been reset. Please re-authenticate using your new token"
}
},
"issues": {
+4 -4
View File
@@ -28,7 +28,7 @@ class HabiticaSwitchEntityDescription(SwitchEntityDescription):
turn_on_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
turn_off_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
is_on_fn: Callable[[HabiticaData], bool]
is_on_fn: Callable[[HabiticaData], bool | None]
class HabiticaSwitchEntity(StrEnum):
@@ -42,9 +42,9 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = (
key=HabiticaSwitchEntity.SLEEP,
translation_key=HabiticaSwitchEntity.SLEEP,
device_class=SwitchDeviceClass.SWITCH,
turn_on_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(),
turn_off_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(),
is_on_fn=lambda data: data.user["preferences"]["sleep"],
turn_on_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
is_on_fn=lambda data: data.user.preferences.sleep,
),
)
+57 -57
View File
@@ -2,11 +2,12 @@
from __future__ import annotations
import datetime
from enum import StrEnum
from typing import TYPE_CHECKING
from uuid import UUID
from aiohttp import ClientResponseError
from aiohttp import ClientError
from habiticalib import Direction, HabiticaException, Task, TaskType
from homeassistant.components import persistent_notification
from homeassistant.components.todo import (
@@ -24,7 +25,7 @@ from homeassistant.util import dt as dt_util
from .const import ASSETS_URL, DOMAIN
from .coordinator import HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
from .types import HabiticaConfigEntry, HabiticaTaskType
from .types import HabiticaConfigEntry
from .util import next_due_date
PARALLEL_UPDATES = 1
@@ -70,8 +71,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
"""Delete Habitica tasks."""
if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS:
try:
await self.coordinator.api.tasks.clearCompletedTodos.post()
except ClientResponseError as e:
await self.coordinator.habitica.delete_completed_todos()
except (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="delete_completed_todos_failed",
@@ -79,8 +80,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
else:
for task_id in uids:
try:
await self.coordinator.api.tasks[task_id].delete()
except ClientResponseError as e:
await self.coordinator.habitica.delete_task(UUID(task_id))
except (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"delete_{self.entity_description.key}_failed",
@@ -106,9 +107,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
pos = 0
try:
await self.coordinator.api.tasks[uid].move.to[str(pos)].post()
except ClientResponseError as e:
await self.coordinator.habitica.reorder_task(UUID(uid), pos)
except (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"move_{self.entity_description.key}_item_failed",
@@ -118,12 +118,14 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
# move tasks in the coordinator until we have fresh data
tasks = self.coordinator.data.tasks
new_pos = (
tasks.index(next(task for task in tasks if task["id"] == previous_uid))
tasks.index(
next(task for task in tasks if task.id == UUID(previous_uid))
)
+ 1
if previous_uid
else 0
)
old_pos = tasks.index(next(task for task in tasks if task["id"] == uid))
old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid)))
tasks.insert(new_pos, tasks.pop(old_pos))
await self.coordinator.async_request_refresh()
@@ -138,14 +140,17 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
if TYPE_CHECKING:
assert item.uid
assert current_item
assert item.summary
task = Task(
text=item.summary,
notes=item.description or "",
)
if (
self.entity_description.key is HabiticaTodoList.TODOS
and item.due is not None
): # Only todos support a due date.
date = item.due.isoformat()
else:
date = None
task["date"] = item.due
if (
item.summary != current_item.summary
@@ -153,13 +158,9 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
or item.due != current_item.due
):
try:
await self.coordinator.api.tasks[item.uid].put(
text=item.summary,
notes=item.description or "",
date=date,
)
await self.coordinator.habitica.update_task(UUID(item.uid), task)
refresh_required = True
except ClientResponseError as e:
except (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"update_{self.entity_description.key}_item_failed",
@@ -172,32 +173,33 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
current_item.status is TodoItemStatus.NEEDS_ACTION
and item.status == TodoItemStatus.COMPLETED
):
score_result = (
await self.coordinator.api.tasks[item.uid].score["up"].post()
score_result = await self.coordinator.habitica.update_score(
UUID(item.uid), Direction.UP
)
refresh_required = True
elif (
current_item.status is TodoItemStatus.COMPLETED
and item.status == TodoItemStatus.NEEDS_ACTION
):
score_result = (
await self.coordinator.api.tasks[item.uid].score["down"].post()
score_result = await self.coordinator.habitica.update_score(
UUID(item.uid), Direction.DOWN
)
refresh_required = True
else:
score_result = None
except ClientResponseError as e:
except (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"score_{self.entity_description.key}_item_failed",
translation_placeholders={"name": item.summary or ""},
) from e
if score_result and (drop := score_result.get("_tmp", {}).get("drop", False)):
if score_result and score_result.data.tmp.drop.key:
drop = score_result.data.tmp.drop
msg = (
f"![{drop["key"]}]({ASSETS_URL}Pet_{drop["type"]}_{drop["key"]}.png)\n"
f"{drop["dialog"]}"
f"![{drop.key}]({ASSETS_URL}Pet_{drop.Type}_{drop.key}.png)\n"
f"{drop.dialog}"
)
persistent_notification.async_create(
self.hass, message=msg, title="Habitica"
@@ -229,38 +231,36 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
return [
*(
TodoItem(
uid=task["id"],
summary=task["text"],
description=task["notes"],
due=(
dt_util.as_local(
datetime.datetime.fromisoformat(task["date"])
).date()
if task.get("date")
else None
),
uid=str(task.id),
summary=task.text,
description=task.notes,
due=dt_util.as_local(task.date).date() if task.date else None,
status=(
TodoItemStatus.NEEDS_ACTION
if not task["completed"]
if not task.completed
else TodoItemStatus.COMPLETED
),
)
for task in self.coordinator.data.tasks
if task["type"] == HabiticaTaskType.TODO
if task.Type is TaskType.TODO
),
]
async def async_create_todo_item(self, item: TodoItem) -> None:
"""Create a Habitica todo."""
if TYPE_CHECKING:
assert item.summary
assert item.description
try:
await self.coordinator.api.tasks.user.post(
text=item.summary,
type=HabiticaTaskType.TODO,
notes=item.description,
date=item.due.isoformat() if item.due else None,
await self.coordinator.habitica.create_task(
Task(
text=item.summary,
type=TaskType.TODO,
notes=item.description,
date=item.due,
)
)
except ClientResponseError as e:
except (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"create_{self.entity_description.key}_item_failed",
@@ -295,23 +295,23 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
that have been completed but forgotten to mark as completed before resetting the dailies.
Changes of the date input field in Home Assistant will be ignored.
"""
last_cron = self.coordinator.data.user["lastCron"]
if TYPE_CHECKING:
assert self.coordinator.data.user.lastCron
return [
*(
TodoItem(
uid=task["id"],
summary=task["text"],
description=task["notes"],
due=next_due_date(task, last_cron),
uid=str(task.id),
summary=task.text,
description=task.notes,
due=next_due_date(task, self.coordinator.data.user.lastCron),
status=(
TodoItemStatus.COMPLETED
if task["completed"]
if task.completed
else TodoItemStatus.NEEDS_ACTION
),
)
for task in self.coordinator.data.tasks
if task["type"] == HabiticaTaskType.DAILY
if task.Type is TaskType.DAILY
)
]
+45 -75
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
from dataclasses import fields
import datetime
from math import floor
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from dateutil.rrule import (
DAILY,
@@ -20,6 +21,7 @@ from dateutil.rrule import (
YEARLY,
rrule,
)
from habiticalib import ContentData, Frequency, TaskData, UserData
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
@@ -27,50 +29,32 @@ from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
def next_due_date(task: dict[str, Any], last_cron: str) -> datetime.date | None:
def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | None:
"""Calculate due date for dailies and yesterdailies."""
if task["everyX"] == 0 or not task.get("nextDue"): # grey dailies never become due
if task.everyX == 0 or not task.nextDue: # grey dailies never become due
return None
today = to_date(last_cron)
startdate = to_date(task["startDate"])
if TYPE_CHECKING:
assert today
assert startdate
assert task.startDate
if task["isDue"] and not task["completed"]:
return to_date(last_cron)
if task.isDue is True and not task.completed:
return dt_util.as_local(today).date()
if startdate > today:
if task["frequency"] == "daily" or (
task["frequency"] in ("monthly", "yearly") and task["daysOfMonth"]
if task.startDate > today:
if task.frequency is Frequency.DAILY or (
task.frequency in (Frequency.MONTHLY, Frequency.YEARLY) and task.daysOfMonth
):
return startdate
return dt_util.as_local(task.startDate).date()
if (
task["frequency"] in ("weekly", "monthly")
and (nextdue := to_date(task["nextDue"][0]))
and startdate > nextdue
task.frequency in (Frequency.WEEKLY, Frequency.MONTHLY)
and (nextdue := task.nextDue[0])
and task.startDate > nextdue
):
return to_date(task["nextDue"][1])
return dt_util.as_local(task.nextDue[1]).date()
return to_date(task["nextDue"][0])
def to_date(date: str) -> datetime.date | None:
"""Convert an iso date to a datetime.date object."""
try:
return dt_util.as_local(datetime.datetime.fromisoformat(date)).date()
except ValueError:
# sometimes nextDue dates are JavaScript datetime strings instead of iso:
# "Mon May 06 2024 00:00:00 GMT+0200"
try:
return dt_util.as_local(
datetime.datetime.strptime(date, "%a %b %d %Y %H:%M:%S %Z%z")
).date()
except ValueError:
return None
return dt_util.as_local(task.nextDue[0]).date()
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
@@ -84,30 +68,27 @@ FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly":
WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU}
def build_rrule(task: dict[str, Any]) -> rrule:
def build_rrule(task: TaskData) -> rrule:
"""Build rrule string."""
rrule_frequency = FREQUENCY_MAP.get(task["frequency"], DAILY)
weekdays = [
WEEKDAY_MAP[day] for day, is_active in task["repeat"].items() if is_active
]
if TYPE_CHECKING:
assert task.frequency
assert task.everyX
rrule_frequency = FREQUENCY_MAP.get(task.frequency, DAILY)
weekdays = [day for key, day in WEEKDAY_MAP.items() if getattr(task.repeat, key)]
bymonthday = (
task["daysOfMonth"]
if rrule_frequency == MONTHLY and task["daysOfMonth"]
else None
task.daysOfMonth if rrule_frequency == MONTHLY and task.daysOfMonth else None
)
bysetpos = None
if rrule_frequency == MONTHLY and task["weeksOfMonth"]:
bysetpos = task["weeksOfMonth"]
if rrule_frequency == MONTHLY and task.weeksOfMonth:
bysetpos = task.weeksOfMonth
weekdays = weekdays if weekdays else [MO]
return rrule(
freq=rrule_frequency,
interval=task["everyX"],
dtstart=dt_util.start_of_local_day(
datetime.datetime.fromisoformat(task["startDate"])
),
interval=task.everyX,
dtstart=dt_util.start_of_local_day(task.startDate),
byweekday=weekdays if rrule_frequency in [WEEKLY, MONTHLY] else None,
bymonthday=bymonthday,
bysetpos=bysetpos,
@@ -143,48 +124,37 @@ def get_recurrence_rule(recurrence: rrule) -> str:
def get_attribute_points(
user: dict[str, Any], content: dict[str, Any], attribute: str
user: UserData, content: ContentData, attribute: str
) -> dict[str, float]:
"""Get modifiers contributing to strength attribute."""
gear_set = {
"weapon",
"armor",
"head",
"shield",
"back",
"headAccessory",
"eyewear",
"body",
}
"""Get modifiers contributing to STR/INT/CON/PER attributes."""
equipment = sum(
stats[attribute]
for gear in gear_set
if (equipped := user["items"]["gear"]["equipped"].get(gear))
and (stats := content["gear"]["flat"].get(equipped))
getattr(stats, attribute)
for gear in fields(user.items.gear.equipped)
if (equipped := getattr(user.items.gear.equipped, gear.name))
and (stats := content.gear.flat[equipped])
)
class_bonus = sum(
stats[attribute] / 2
for gear in gear_set
if (equipped := user["items"]["gear"]["equipped"].get(gear))
and (stats := content["gear"]["flat"].get(equipped))
and stats["klass"] == user["stats"]["class"]
getattr(stats, attribute) / 2
for gear in fields(user.items.gear.equipped)
if (equipped := getattr(user.items.gear.equipped, gear.name))
and (stats := content.gear.flat[equipped])
and stats.klass == user.stats.Class
)
if TYPE_CHECKING:
assert user.stats.lvl
return {
"level": min(floor(user["stats"]["lvl"] / 2), 50),
"level": min(floor(user.stats.lvl / 2), 50),
"equipment": equipment,
"class": class_bonus,
"allocated": user["stats"][attribute],
"buffs": user["stats"]["buffs"][attribute],
"allocated": getattr(user.stats, attribute),
"buffs": getattr(user.stats.buffs, attribute),
}
def get_attributes_total(
user: dict[str, Any], content: dict[str, Any], attribute: str
) -> int:
def get_attributes_total(user: UserData, content: ContentData, attribute: str) -> int:
"""Get total attribute points."""
return floor(
sum(value for value in get_attribute_points(user, content, attribute).values())
+36 -73
View File
@@ -10,7 +10,6 @@ from typing import Any, cast
from aiohasupervisor.exceptions import (
SupervisorBadRequestError,
SupervisorError,
SupervisorNotFoundError,
)
from aiohasupervisor.models import (
@@ -24,10 +23,8 @@ from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupReaderWriter,
BackupReaderWriterError,
CreateBackupEvent,
Folder,
IncorrectPasswordError,
NewBackup,
WrittenBackup,
)
@@ -216,10 +213,6 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
password: str | None,
) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]:
"""Create a backup."""
if not include_homeassistant and include_database:
raise HomeAssistantError(
"Cannot create a backup with database but without Home Assistant"
)
manager = self._hass.data[DATA_MANAGER]
include_addons_set: supervisor_backups.AddonSet | set[str] | None = None
@@ -240,23 +233,20 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
]
locations = [agent.location for agent in hassio_agents]
try:
backup = await self._client.backups.partial_backup(
supervisor_backups.PartialBackupOptions(
addons=include_addons_set,
folders=include_folders_set,
homeassistant=include_homeassistant,
name=backup_name,
password=password,
compressed=True,
location=locations or LOCATION_CLOUD_BACKUP,
homeassistant_exclude_database=not include_database,
background=True,
extra=extra_metadata,
)
backup = await self._client.backups.partial_backup(
supervisor_backups.PartialBackupOptions(
addons=include_addons_set,
folders=include_folders_set,
homeassistant=include_homeassistant,
name=backup_name,
password=password,
compressed=True,
location=locations or LOCATION_CLOUD_BACKUP,
homeassistant_exclude_database=not include_database,
background=True,
extra=extra_metadata,
)
except SupervisorError as err:
raise BackupReaderWriterError(f"Error creating backup: {err}") from err
)
backup_task = self._hass.async_create_task(
self._async_wait_for_backup(
backup, remove_after_upload=not bool(locations)
@@ -288,35 +278,22 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
finally:
unsub()
if not backup_id:
raise BackupReaderWriterError("Backup failed")
raise HomeAssistantError("Backup failed")
async def open_backup() -> AsyncIterator[bytes]:
try:
return await self._client.backups.download_backup(backup_id)
except SupervisorError as err:
raise BackupReaderWriterError(
f"Error downloading backup: {err}"
) from err
return await self._client.backups.download_backup(backup_id)
async def remove_backup() -> None:
if not remove_after_upload:
return
try:
await self._client.backups.remove_backup(
backup_id,
options=supervisor_backups.RemoveBackupOptions(
location={LOCATION_CLOUD_BACKUP}
),
)
except SupervisorError as err:
raise BackupReaderWriterError(f"Error removing backup: {err}") from err
await self._client.backups.remove_backup(
backup_id,
options=supervisor_backups.RemoveBackupOptions(
location={LOCATION_CLOUD_BACKUP}
),
)
try:
details = await self._client.backups.backup_info(backup_id)
except SupervisorError as err:
raise BackupReaderWriterError(
f"Error getting backup details: {err}"
) from err
details = await self._client.backups.backup_info(backup_id)
return WrittenBackup(
backup=_backup_details_to_agent_backup(details),
@@ -382,16 +359,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
restore_homeassistant: bool,
) -> None:
"""Restore a backup."""
manager = self._hass.data[DATA_MANAGER]
# The backup manager has already checked that the backup exists so we don't need to
# check that here.
backup = await manager.backup_agents[agent_id].async_get_backup(backup_id)
if (
backup
and restore_homeassistant
and restore_database != backup.database_included
):
raise HomeAssistantError("Restore database must match backup")
if restore_homeassistant and not restore_database:
raise HomeAssistantError("Cannot restore Home Assistant without database")
if not restore_homeassistant and restore_database:
raise HomeAssistantError("Cannot restore database without Home Assistant")
restore_addons_set = set(restore_addons) if restore_addons else None
@@ -401,6 +370,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
else None
)
manager = self._hass.data[DATA_MANAGER]
restore_location: str | None
if manager.backup_agents[agent_id].domain != DOMAIN:
# Download the backup to the supervisor. Supervisor will clean up the backup
@@ -415,24 +385,17 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
agent = cast(SupervisorBackupAgent, manager.backup_agents[agent_id])
restore_location = agent.location
try:
job = await self._client.backups.partial_restore(
backup_id,
supervisor_backups.PartialRestoreOptions(
addons=restore_addons_set,
folders=restore_folders_set,
homeassistant=restore_homeassistant,
password=password,
background=True,
location=restore_location,
),
)
except SupervisorBadRequestError as err:
# Supervisor currently does not transmit machine parsable error types
message = err.args[0]
if message.startswith("Invalid password for backup"):
raise IncorrectPasswordError(message) from err
raise HomeAssistantError(message) from err
job = await self._client.backups.partial_restore(
backup_id,
supervisor_backups.PartialRestoreOptions(
addons=restore_addons_set,
folders=restore_folders_set,
homeassistant=restore_homeassistant,
password=password,
background=True,
location=restore_location,
),
)
restore_complete = asyncio.Event()
+3 -3
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from datetime import timedelta
import logging
from pyheos import Heos, HeosError, HeosPlayer, const as heos_const
from pyheos import Heos, HeosError, HeosOptions, HeosPlayer, const as heos_const
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
@@ -58,9 +58,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
host = entry.data[CONF_HOST]
# Setting all_progress_events=False ensures that we only receive a
# media position update upon start of playback or when media changes
controller = Heos(host, all_progress_events=False)
controller = Heos(HeosOptions(host, all_progress_events=False, auto_reconnect=True))
try:
await controller.connect(auto_reconnect=True)
await controller.connect()
# Auto reconnect only operates if initial connection was successful.
except HeosError as error:
await controller.disconnect()
+2 -2
View File
@@ -3,7 +3,7 @@
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from pyheos import Heos, HeosError
from pyheos import Heos, HeosError, HeosOptions
import voluptuous as vol
from homeassistant.components import ssdp
@@ -20,7 +20,7 @@ def format_title(host: str) -> str:
async def _validate_host(host: str, errors: dict[str, str]) -> bool:
"""Validate host is reachable, return True, otherwise populate errors and return False."""
heos = Heos(host)
heos = Heos(HeosOptions(host, events=False, heart_beat=False))
try:
await heos.connect()
except HeosError:
+1 -1
View File
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/heos",
"iot_class": "local_push",
"loggers": ["pyheos"],
"requirements": ["pyheos==0.7.2"],
"requirements": ["pyheos==0.8.0"],
"single_config_entry": true,
"ssdp": [
{
-1
View File
@@ -114,7 +114,6 @@ class HiveDeviceLight(HiveEntity, LightEntity):
self._attr_hs_color = color_util.color_RGB_to_hs(*rgb)
self._attr_color_mode = ColorMode.HS
else:
color_temp = self.device["status"].get("color_temp")
self._attr_color_temp_kelvin = (
None
if color_temp is None
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.64", "babel==2.15.0"]
"requirements": ["holidays==0.63", "babel==2.15.0"]
}
@@ -168,7 +168,7 @@ async def _run_appliance_service[*_Ts](
error_translation_placeholders: dict[str, str],
) -> None:
try:
await hass.async_add_executor_job(getattr(appliance, method), *args)
await hass.async_add_executor_job(getattr(appliance, method), args)
except api.HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -220,7 +220,7 @@ async def async_setup_entry(
with contextlib.suppress(HomeConnectError):
programs = device.appliance.get_programs_available()
if programs:
for program in programs.copy():
for program in programs:
if program not in PROGRAMS_TRANSLATION_KEYS_MAP:
programs.remove(program)
if program not in programs_not_found:
@@ -12,6 +12,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
"requirements": ["python-homewizard-energy==v7.0.1"],
"requirements": ["python-homewizard-energy==v7.0.0"],
"zeroconf": ["_hwenergy._tcp.local."]
}
@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2025.1.0"]
"requirements": ["aioautomower==2024.12.0"]
}
@@ -12,5 +12,6 @@
"documentation": "https://www.home-assistant.io/integrations/idasen_desk",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["idasen-ha==2.6.3"]
}
@@ -17,9 +17,9 @@ rules:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: todo
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: todo
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
@@ -20,6 +20,8 @@ from . import InComfortConfigEntry
from .coordinator import InComfortDataCoordinator
from .entity import IncomfortBoilerEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class IncomfortBinarySensorEntityDescription(BinarySensorEntityDescription):
@@ -22,6 +22,8 @@ from .const import DOMAIN
from .coordinator import InComfortDataCoordinator
from .entity import IncomfortEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
@@ -22,6 +22,8 @@ from . import InComfortConfigEntry
from .coordinator import InComfortDataCoordinator
from .entity import IncomfortBoilerEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class IncomfortSensorEntityDescription(SensorEntityDescription):
@@ -20,6 +20,8 @@ _LOGGER = logging.getLogger(__name__)
HEATER_ATTRS = ["display_code", "display_text", "is_burning"]
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
@@ -385,7 +385,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity):
@callback
def async_set_datetime(self, date=None, time=None, datetime=None, timestamp=None):
"""Set a new date / time."""
if timestamp is not None:
if timestamp:
datetime = dt_util.as_local(dt_util.utc_from_timestamp(timestamp))
if datetime:
+1 -1
View File
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ipma",
"iot_class": "cloud_polling",
"loggers": ["geopy", "pyipma"],
"requirements": ["pyipma==3.0.7"]
"requirements": ["pyipma==3.0.8"]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyiqvia"],
"requirements": ["numpy==2.2.0", "pyiqvia==2022.04.0"]
"requirements": ["numpy==2.2.1", "pyiqvia==2022.04.0"]
}
@@ -27,9 +27,11 @@ from .coordinator import (
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
@@ -0,0 +1,85 @@
"""Button platform for IronOS integration."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from pynecil import CharSetting
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import IronOSConfigEntry
from .coordinator import IronOSCoordinators
from .entity import IronOSBaseEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class IronOSButtonEntityDescription(ButtonEntityDescription):
"""Describes IronOS button entity."""
characteristic: CharSetting
class IronOSButton(StrEnum):
"""Button controls for IronOS device."""
SETTINGS_RESET = "settings_reset"
SETTINGS_SAVE = "settings_save"
BUTTON_DESCRIPTIONS: tuple[IronOSButtonEntityDescription, ...] = (
IronOSButtonEntityDescription(
key=IronOSButton.SETTINGS_RESET,
translation_key=IronOSButton.SETTINGS_RESET,
characteristic=CharSetting.SETTINGS_RESET,
entity_registry_enabled_default=False,
entity_category=EntityCategory.CONFIG,
),
IronOSButtonEntityDescription(
key=IronOSButton.SETTINGS_SAVE,
translation_key=IronOSButton.SETTINGS_SAVE,
characteristic=CharSetting.SETTINGS_SAVE,
entity_category=EntityCategory.CONFIG,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: IronOSConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up button entities from a config entry."""
coordinators = entry.runtime_data
async_add_entities(
IronOSButtonEntity(coordinators, description)
for description in BUTTON_DESCRIPTIONS
)
class IronOSButtonEntity(IronOSBaseEntity, ButtonEntity):
"""Implementation of a IronOS button entity."""
entity_description: IronOSButtonEntityDescription
def __init__(
self,
coordinators: IronOSCoordinators,
entity_description: IronOSButtonEntityDescription,
) -> None:
"""Initialize the select entity."""
super().__init__(coordinators.live_data, entity_description)
self.settings = coordinators.settings
async def async_press(self) -> None:
"""Handle the button press."""
await self.settings.write(self.entity_description.characteristic, True)
@@ -5,8 +5,10 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import cast
from pynecil import (
CharSetting,
CommunicationError,
DeviceInfoResponse,
IronOSUpdate,
@@ -19,6 +21,7 @@ from pynecil import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -147,3 +150,21 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]):
_LOGGER.debug("Failed to fetch settings", exc_info=e)
return self.data or SettingsDataResponse()
async def write(self, characteristic: CharSetting, value: bool) -> None:
"""Write value to the settings characteristic."""
try:
await self.device.write(characteristic, value)
except CommunicationError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="submit_setting_failed",
) from e
# prevent switch bouncing while waiting for coordinator to finish refresh
self.data.update(
cast(SettingsDataResponse, {characteristic.name.lower(): value})
)
self.async_update_listeners()
await self.async_request_refresh()

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