Compare commits

..

172 Commits

Author SHA1 Message Date
Franck Nijhof d59a91a905 2025.1.1 (#134940) 2025-01-07 08:43:32 +01:00
Franck Nijhof 298f059488 Revert "Remove deprecated supported features warning in ..." (multiple) (#134933) 2025-01-07 06:53:14 +00:00
Franck Nijhof 7a5525951d Bump version to 2025.1.1 2025-01-06 23:42:21 +00:00
Artur Pragacz 9a9514d53b Revert "Remove deprecated supported features warning in LightEntity" (#134927) 2025-01-06 23:42:00 +00:00
G Johansson 5337ab2e72 Bump holidays to 0.64 (#134922) 2025-01-06 23:41:55 +00:00
Klaas Schoute b815899fdc Bump powerfox to v1.2.0 (#134908) 2025-01-06 23:41:51 +00:00
Klaas Schoute 81a669c163 Bump powerfox to v1.1.0 (#134730) 2025-01-06 23:41:45 +00:00
Bram Kragten 188def51c6 Update frontend to 20250106.0 (#134905) 2025-01-06 23:40:07 +00:00
Manu eb345971b4 Fix wrong power limit decimal place in IronOS (#134902) 2025-01-06 23:40:03 +00:00
Manu 9288dce7ed Add bring_api to loggers in Bring integration (#134897)
Add bring-api to loggers
2025-01-06 23:39:59 +00:00
Steven B. 4867d3a187 Bump python-kasa to 0.9.1 (#134893)
Bump tplink python-kasa dependency to 0.9.1
2025-01-06 23:39:55 +00:00
Norbert Rittel c40771ba6a Use uppercase for "ID" and sentence-case for "name" / "icon" (#134890) 2025-01-06 23:39:51 +00:00
Luke Lashley 2fc489d17d Add extra failure exceptions during roborock setup (#134889)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-01-06 23:39:47 +00:00
Robin Wohlers-Reichel 279785b22e Bump solax to 3.2.3 (#134876) 2025-01-06 23:39:42 +00:00
Joakim Sørensen e5c986171b Log cloud backup upload response status (#134871)
Log the status of the upload response
2025-01-06 23:39:38 +00:00
Joakim Sørensen 58805f721c Log upload BackupAgentError (#134865)
* Log out BackupAgentError

* Update homeassistant/components/backup/manager.py

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

* Update homeassistant/components/backup/manager.py

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

* Format

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-01-06 23:39:33 +00:00
Allen Porter 29989e9034 Update Roborock config flow message when an account is already configured (#134854) 2025-01-06 23:39:28 +00:00
Avi Miller fbd031a03d Bump aiolifx-themes to update colors (#134846) 2025-01-06 23:39:23 +00:00
J. Diego Rodríguez Royo fe1ce39831 Fix how function arguments are passed on actions at Home Connect (#134845) 2025-01-06 23:39:19 +00:00
J. Nick Koston 914c6459dc Bump habluetooth to 3.7.0 (#134833) 2025-01-06 23:39:14 +00:00
Raphael Hehl 43ffdd0eef Bump uiprotect to version 7.4.1 (#134829) 2025-01-06 23:39:10 +00:00
Norbert Rittel 39d16ed5ce Fix a few typos or grammar issues in asus_wrt (#134813) 2025-01-06 23:39:06 +00:00
Norbert Rittel 07f3d939e3 Replace "id" with "ID" for consistency across HA (#134798) 2025-01-06 23:39:01 +00:00
G Johansson eda60073ee Raise ImportError in python_script (#134792) 2025-01-06 23:38:57 +00:00
Norbert Rittel 09ffa38ddf Fix missing sentence-casing etc. in several strings (#134775) 2025-01-06 23:38:53 +00:00
jb101010-2 b32a791ea4 Bump pysuezV2 to 2.0.1 (#134769) 2025-01-06 23:38:48 +00:00
Michael a4ea25631a Register base device entry during coordinator setup in AVM Fritz!Tools integration (#134764)
* register base device entry during coordinator setup

* make mypy happy
2025-01-06 23:38:44 +00:00
Duco Sebel bd8ea646a9 Bumb python-homewizard-energy to 7.0.1 (#134753) 2025-01-06 23:38:38 +00:00
Norbert Rittel 538a2ea057 Fix swapped letter order in "°F" and "°C" temperature units (#134750)
Fixes the wrong order "F°" and "C°" for the temperature units.
2025-01-06 23:38:34 +00:00
Sid b461bc2fb5 Bump openwebifpy to 4.3.1 (#134746) 2025-01-06 23:38:29 +00:00
TheJulianJES 103960e0a7 Bump ZHA to 0.0.45 (#134726) 2025-01-06 23:37:24 +00:00
dontinelli 1c4273ce91 Change from host to ip in zeroconf discovery for slide_local (#134709) 2025-01-06 23:34:17 +00:00
J. Diego Rodríguez Royo 0f0209d4bb Iterate over a copy of the list of programs at Home Connect select setup entry (#134684) 2025-01-06 23:34:13 +00:00
Cyrill Raccaud 27b8b8458b Cookidoo exotic domains (#134676) 2025-01-06 23:34:08 +00:00
Franck Nijhof c022d91baa Update demetriek to 1.1.1 (#134663) 2025-01-06 23:34:02 +00:00
Cyrill Raccaud 0daac09008 Bump cookidoo-api library to 0.11.1 of for Cookidoo (#134661) 2025-01-06 23:33:56 +00:00
Franck Nijhof ca8416fe50 Update peblar to 0.3.3 (#134658) 2025-01-06 23:33:50 +00:00
starkillerOG a14f6faaaf Fix Reolink playback of recodings (#134652) 2025-01-06 23:33:45 +00:00
Franck Nijhof a9a14381d3 Update twentemilieu to 2.2.1 (#134651) 2025-01-06 23:33:39 +00:00
Joost Lekkerkerker a4d0794fe4 Remove call to remove slide (#134647) 2025-01-06 23:33:33 +00:00
Cyrill Raccaud 9ead6fe362 Set logging in manifest for Cookidoo (#134645) 2025-01-06 23:33:28 +00:00
epenet 017679abe1 Fix hive color tunable light (#134628) 2025-01-06 23:33:23 +00:00
Brynley McDonald 0bd7b793fe Fix Flick Electric authentication (#134611) 2025-01-06 23:33:19 +00:00
Teemu R. c46a70fdcf Mention case-sensitivity in tplink credentials prompt (#134606) 2025-01-06 23:33:13 +00:00
Raphael Hehl 8c2ec5e7c8 Bump uiprotect to version 7.2.0 (#134587) 2025-01-06 23:33:09 +00:00
J. Nick Koston 3063f0b565 Bump bleak-esphome to 2.0.0 (#134580) 2025-01-06 23:33:04 +00:00
peteS-UK aafc1ff074 Small fix to allow playing of expandable favorites on Squeezebox (#134572) 2025-01-06 23:33:00 +00:00
Ludovic BOUÉ 45142b0cc0 Matter Battery replacement icon (#134460) 2025-01-06 23:32:54 +00:00
Franck Nijhof a412acec0e 2025.1.0 (#134529)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: VandeurenGlenn <8685280+VandeurenGlenn@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: Allen Porter <allen.porter@gmail.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Teemu R. <tpr@iki.fi>
Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: OzGav <gavnosp@hotmail.com>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
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: Sander Hoentjen <sander@hoentjen.eu>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Richard Kroegel <42204099+rikroe@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Álvaro Fernández Rojas <noltari@gmail.com>
Co-authored-by: Maciej Bieniek <bieniu@users.noreply.github.com>
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
Co-authored-by: Arie Catsman <120491684+catsmanac@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Matthias Alphart <farmio@alphart.net>
Co-authored-by: Tom <CoMPaTech@users.noreply.github.com>
Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
Co-authored-by: Glenn Vandeuren (aka Iondependent) <vandeurenglenn@gmail.com>
Co-authored-by: Austin Mroczek <austin@mroczek.org>
Co-authored-by: Mick Vleeshouwer <mick@imick.nl>
Co-authored-by: PierreAronnax <pierre@trionax.com>
Co-authored-by: Dave T <17680170+davet2001@users.noreply.github.com>
Co-authored-by: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com>
Co-authored-by: Steven Looman <steven.looman@gmail.com>
Co-authored-by: Barry vd. Heuvel <barry@fruitcake.nl>
Co-authored-by: Raphael Hehl <7577984+RaHehl@users.noreply.github.com>
Co-authored-by: Andre Lengwenus <alengwenus@gmail.com>
Co-authored-by: dontinelli <73341522+dontinelli@users.noreply.github.com>
Co-authored-by: Noah Husby <32528627+noahhusby@users.noreply.github.com>
Co-authored-by: Lucas Gasenzer <lucasgasenzer@mac.com>
Co-authored-by: jb101010-2 <168106462+jb101010-2@users.noreply.github.com>
Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
Co-authored-by: Martin Weinelt <mweinelt@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jon6fingrs <53415122+jon6fingrs@users.noreply.github.com>
Co-authored-by: mrtlhfr <10065880+mrtlhfr@users.noreply.github.com>
Co-authored-by: Matrix <justin@yosmart.com>
Co-authored-by: Duco Sebel <74970928+DCSBL@users.noreply.github.com>
Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Omni Flux <omni.hyper.flux@gmail.com>
Co-authored-by: starkillerOG <starkiller.og@gmail.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Thomas55555 <59625598+Thomas55555@users.noreply.github.com>
Co-authored-by: karwosts <32912880+karwosts@users.noreply.github.com>
Co-authored-by: Jordi <Jordi1990@users.noreply.github.com>
Co-authored-by: Martin Mrazik <mmrazik@users.noreply.github.com>
Co-authored-by: Brett Adams <Bre77@users.noreply.github.com>
Co-authored-by: G-Two <7310260+G-Two@users.noreply.github.com>
Co-authored-by: Claudio Ruggeri - CR-Tech <41435902+crug80@users.noreply.github.com>
Co-authored-by: Khole <29937485+KJonline@users.noreply.github.com>
Co-authored-by: Philipp Danner <philipp@danner-web.de>
Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
Co-authored-by: Cyrill Raccaud <miaucl@users.noreply.github.com>
Co-authored-by: Allen Porter <allen@thebends.org>
Co-authored-by: Aaron Bach <bachya1208@gmail.com>
Co-authored-by: Michael Hansen <mike@rhasspy.org>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Paul Daumlechner <paul.daumlechner@live.de>
Co-authored-by: Adam Goode <agoode@google.com>
Co-authored-by: Alberto Geniola <albertogeniola@users.noreply.github.com>
Co-authored-by: tronikos <tronikos@users.noreply.github.com>
Co-authored-by: Arne Keller <arne.keller@posteo.de>
Co-authored-by: Andrew Jackson <andrew@codechimp.org>
Co-authored-by: Brynley McDonald <brynley+github@zephire.nz>
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
Co-authored-by: Niels Mündler <niels.muendler@inf.ethz.ch>
Co-authored-by: Craig Andrews <candrews@integralblue.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Matthew FitzGerald-Chamberlain <mattfitzgeraldchamberlain@proton.me>
Co-authored-by: Adam Štrauch <cx@initd.cz>
Co-authored-by: cdnninja <jaydenaphillips@gmail.com>
Co-authored-by: Stefan Agner <stefan@agner.ch>
Co-authored-by: Kenny Root <kenny@the-b.org>
Co-authored-by: Krzysztof Dąbrowski <krzysdabro@live.com>
Co-authored-by: Andrea Arcangeli <aagit@users.noreply.github.com>
Co-authored-by: Robert Svensson <Kane610@users.noreply.github.com>
Fix section translations check (#133683)
Fix test coverage in workday (#133616)
Fix spelling of "Gateway PIN" and remove two excessive spaces (#133716)
Fix Peblar current limit user setting value (#133753)
Fix binary_sensor typing in Overkiz (#133782)
Fix errors in HitachiDHW in Overkiz (#133765)
Fix typo in ElevenLabs (#133819)
fixture from LCN tests (#133821)
fix yesterday sensor extra_state invalid typing (#133425)
Fix TypeError in maxcube climate action inference logic (#133853)
Fix tplink camera entity unique id (#133880)
Fix a history stats bug when window and tracked state change simultaneously (#133770)
fixes #133904
Fix duplicate call to async_register_preload_platform (#133909)
Fix missing % in string for generic camera (#133925)
Fix Peblar import in data coordinator (#133926)
Fix reload modbus component issue (#133820)
Fix error when device goes offline (#133848)
fix "Slow" response leads to "Could not find a charging station" #124129 (#133889)
fix #124129
Fix swiss public transport line field none (#133964)
fix #133116
Fix Nord Pool empty response (#134033)
Fix KNX config flow translations and add data descriptions (#134078)
Fix Wake on LAN Port input as Box instead of Slider (#134216)
Fix duplicate sensor disk entities in Systemmonitor (#134139)
Fix Onkyo volume rounding (#134157)
Fix 400 This voice does not support speaking rate or pitch parameters at this time for Google Cloud Journey voices (#134255)
Fix SQL sensor name (#134414)
Fix a few small typos in peblar (#134481)
Fix input_datetime.set_datetime not accepting 0 timestamp value (#134489)
Fix backup dir not existing (#134506)
Fix activating backup retention config on startup (#134523)
fix generic component tests (#134569)
2025-01-03 19:19:01 +01:00
Franck Nijhof ac4bd32137 Bump version to 2025.1.0 2025-01-03 17:31:21 +00:00
Abílio Costa 7e1e63374f Bump whirlpool-sixth-sense to 0.18.11 (#134562) 2025-01-03 17:31:05 +00:00
Robert Resch 03fd6a901b Cherry pick single file from #134020 to fix generic component tests (#134569) 2025-01-03 18:24:46 +01:00
Franck Nijhof 46b2830699 Bump version to 2025.1.0b9 2025-01-03 15:41:14 +00:00
Bram Kragten b416ae1387 Update frontend to 20250103.0 (#134561) 2025-01-03 15:41:06 +00:00
Erik Montnemery 962b880146 Log cloud backup agent file list (#134556) 2025-01-03 15:41:03 +00:00
Erik Montnemery 9c98125d20 Avoid early COMPLETED event when restoring backup (#134546) 2025-01-03 15:41:00 +00:00
Joost Lekkerkerker c9f1fee6bb Set Ituran to silver (#134538) 2025-01-03 15:40:57 +00:00
Erik Montnemery 9b8ed9643f Add backup as after_dependency of frontend (#134534) 2025-01-03 15:40:54 +00:00
Erik Montnemery 7ea7178aa9 Simplify error handling when creating backup (#134528) 2025-01-03 15:40:51 +00:00
starkillerOG c5746291cc Add Reolink proxy for playback (#133916) 2025-01-03 15:40:46 +00:00
Franck Nijhof 1af384bc0a Bump version to 2025.1.0b8 2025-01-03 09:56:51 +00:00
Franck Nijhof ea82c1b73e Only load Peblar customization update entity when present (#134526) 2025-01-03 09:56:39 +00:00
Franck Nijhof 96936f5f4a Update peblar to v0.3.2 (#134524) 2025-01-03 09:56:36 +00:00
Erik Montnemery 316f93f208 Fix activating backup retention config on startup (#134523) 2025-01-03 09:56:33 +00:00
Robert Svensson f719a14537 Handle deCONZ color temp 0 is never used when calculating kelvin CT (#134521) 2025-01-03 09:56:30 +00:00
Erik Montnemery a830a14342 Improve recorder schema migration error test (#134518) 2025-01-03 09:56:27 +00:00
Erik Montnemery 1b67d51e24 Add error prints for recorder fatal errors (#134517) 2025-01-03 09:56:23 +00:00
Paulus Schoutsen e1f6475623 Fix backup dir not existing (#134506) 2025-01-03 09:56:20 +00:00
Josef Zweck 59a3fe857b Bump aioacaia to 0.1.13 (#134496) 2025-01-03 09:56:17 +00:00
Franck Nijhof f364e29148 Fix input_datetime.set_datetime not accepting 0 timestamp value (#134489) 2025-01-03 09:56:13 +00:00
Franck Nijhof 47190e4ac1 Bump version to 2025.1.0b7 2025-01-02 22:23:54 +00:00
Franck Nijhof 7fa1983da0 Update peblar to 0.3.1 (#134486) 2025-01-02 22:21:44 +00:00
Norbert Rittel 9b906e94c7 Fix a few small typos in peblar (#134481) 2025-01-02 22:21:16 +00:00
Robert Resch 5ac4d5bef7 Bump deebot-client to 10.1.0 (#134470) 2025-01-02 21:36:44 +00:00
Erik Montnemery 995e222959 Don't start recorder if a database from the future is used (#134467) 2025-01-02 21:36:41 +00:00
Duco Sebel 61ac8e7e8c Include host in Peblar EV-Charger discovery setup description (#133954)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-01-02 21:36:38 +00:00
Andrea Arcangeli 67ec71031d open_meteo: correct UTC timezone handling in hourly forecast (#129664)
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2025-01-02 21:36:33 +00:00
Franck Nijhof 59f866bcf7 Bump version to 2025.1.0b6 2025-01-02 17:21:58 +00:00
Bram Kragten d75d970fc7 Update frontend to 20250102.0 (#134462) 2025-01-02 17:21:47 +00:00
Josef Zweck 0a13516ddd Bump aioacaia to 0.1.12 (#134454) 2025-01-02 17:21:43 +00:00
Erik Montnemery 21aca3c146 Initialize AppleTVConfigFlow.identifiers (#134443) 2025-01-02 17:21:40 +00:00
Erik Montnemery faf9c2ee40 Adjust language in backup integration (#134440)
* Adjust language in backup integration

* Update tests
2025-01-02 17:21:37 +00:00
Erik Montnemery e89a1da462 Export IncorrectPasswordError from backup integration (#134436) 2025-01-02 17:21:34 +00:00
Erik Montnemery 8ace126d9f Improve hassio backup create and restore parameter checks (#134434) 2025-01-02 17:21:31 +00:00
TheJulianJES ca6bae6b15 Bump ZHA to 0.0.44 (#134427) 2025-01-02 17:21:28 +00:00
Michael Hansen c9ba267fec Bump intents to 2025.1.1 (#134424) 2025-01-02 17:21:24 +00:00
G Johansson 0e79c17cb8 Fix SQL sensor name (#134414) 2025-01-02 17:21:21 +00:00
Krzysztof Dąbrowski 4cb413521d Add state attributes translations to GIOS (#134390) 2025-01-02 17:21:18 +00:00
Brett Adams f97439eaab Check vehicle metadata (#134381) 2025-01-02 17:21:15 +00:00
Kenny Root 568b637dc5 Bump zabbix-utils to 2.0.2 (#134373) 2025-01-02 17:21:12 +00:00
Stefan Agner 3a8f71a64a Improve Supervisor backup error handling (#134346)
* Raise Home Assistant error in case backup restore fails

This change raises a Home Assistant error in case the backup restore
fails. The Supervisor is checking some common issues before starting
the actual restore in background. This early checks raise an exception
(represented by a HTTP 400 error). This change catches such errors and
raises a Home Assistant error with the message from the Supervisor
exception.

* Add test coverage
2025-01-02 17:21:09 +00:00
cdnninja fea3dfda94 Vesync unload error when not all platforms used (#134166) 2025-01-02 17:21:05 +00:00
Adam Štrauch 554cdd1784 Add new ID LAP-V201S-AEUR for Vital200S AirPurifier in Vesync integration (#133999) 2025-01-02 17:21:02 +00:00
Matthew FitzGerald-Chamberlain ce7a0650e4 Improve support for Aprilaire S86WMUPR (#133974) 2025-01-02 17:20:59 +00:00
Martin Hjelmare 5895aa4cde Handle backup errors more consistently (#133522)
* Add backup manager and read writer errors

* Clean up not needed default argument

* Clean up todo comment

* Trap agent bugs during upload

* Always release stream

* Clean up leftover

* Update test for backup with automatic settings

* Fix use of vol.Any

* Refactor test helper

* Only update successful timestamp if completed event is sent

* Always delete surplus copies

* Fix after rebase

* Fix after rebase

* Revert "Fix use of vol.Any"

This reverts commit 28fd7a544899bb6ed05f771e9e608bc5b41d2b5e.

* Inherit BackupReaderWriterError in IncorrectPasswordError

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-01-02 17:20:52 +00:00
Craig Andrews bd5477729a Improve is docker env checks (#132404)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Sander Hoentjen <sander@hoentjen.eu>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Robert Resch <robert@resch.dev>
2025-01-02 17:20:36 +00:00
Paulus Schoutsen 2e21ac7001 Bump version to 2025.1.0b5 2024-12-31 22:10:20 +00:00
Josef Zweck ab6394b26c Bump pylamarzocco to 1.4.6 (#134367) 2024-12-31 22:10:09 +00:00
Bram Kragten 0ae4a9a911 Update frontend to 20241231.0 (#134363) 2024-12-31 22:10:08 +00:00
Michael Hansen f709989717 Revert speech seconds to 0.3 (#134360) 2024-12-31 22:10:05 +00:00
Michael Hansen 952363eca3 Bump hassil to 2.1.0 (#134359) 2024-12-31 22:10:05 +00:00
Simone Chemelli a7995e0093 Bump aioshelly to 12.2.0 (#134352) 2024-12-31 22:10:04 +00:00
Niels Mündler 1064ef9dc6 Bump pysynthru version to 0.8.0 (#134294) 2024-12-31 22:10:03 +00:00
starkillerOG c2f06fbd47 Bump reolink-aio to 0.11.6 (#134286) 2024-12-31 22:10:02 +00:00
Bram Kragten a36fd09644 Set backup manager state to completed when restore is finished (#134283) 2024-12-31 22:10:01 +00:00
tronikos b89995a79f Allow automations to pass any conversation_id for Google Generative AI (#134251) 2024-12-31 22:10:00 +00:00
Brett Adams c908f823c5 Handle missing application credentials in Tesla Fleet (#134237)
* Handle missing application credentials

* Add tests

* Test reauth starts

* Only catch ValueError
2024-12-31 22:09:59 +00:00
Simone Chemelli 229c32b0da Bump aiocomelit to 0.10.1 (#134214) 2024-12-31 22:09:59 +00:00
Dave T e303a9a2b5 Add stream preview to options flow in generic camera (#133927)
* Add stream preview to options flow

* Increase test coverage

* Code review: use correct flow handler type in cast

* Restore test coverage to 100%

* Remove error and test that can't be triggered yet
2024-12-31 22:09:58 +00:00
Brynley McDonald 54fa30c2b8 Update Flick Electric API (#133475) 2024-12-31 22:09:57 +00:00
Joost Lekkerkerker fbd6cf7244 Improve Mealie set mealplan service (#130606)
* Improve Mealie set mealplan service

* Fix

* Fix
2024-12-31 22:09:56 +00:00
Bram Kragten c10175e25c Bump version to 2025.1.0b4 2024-12-30 20:06:44 +01:00
Bram Kragten 82f0e8cc19 Update frontend to 20241230.0 (#134284) 2024-12-30 20:06:32 +01:00
Andrew Jackson 623e1b08b8 Bump aiomealie to 0.9.5 (#134274) 2024-12-30 20:06:31 +01:00
Norbert Rittel 0c73251004 Remove excessive period at end of action name (#134272) 2024-12-30 20:06:30 +01:00
Arne Keller d9057fc43e ollama: update to 0.4.5 (#134265) 2024-12-30 20:06:29 +01:00
Josef Zweck 077c9e62b4 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 20:06:28 +01:00
tronikos 7456ce1c01 Fix 400 This voice does not support speaking rate or pitch parameters at this time for Google Cloud Journey voices (#134255) 2024-12-30 20:06:28 +01:00
tronikos a627fa70a7 Avoid KeyError for ignored entries in async_step_zeroconf of Android TV Remote (#134250) 2024-12-30 20:06:27 +01:00
Michael c402eaec3f Bump aiopegelonline to 0.1.1 (#134230)
bump aiopegelonline to 0.1.1
2024-12-30 20:06:26 +01:00
tronikos ea51ecd384 Bump opower to 0.8.7 (#134228)
* Bump opower to 0.8.7

* update deps
2024-12-30 20:06:25 +01:00
Artur Pragacz 0873d27d7b Fix Onkyo volume rounding (#134157) 2024-12-30 20:06:23 +01:00
G Johansson 45fd7fb6d5 Fix duplicate sensor disk entities in Systemmonitor (#134139) 2024-12-30 20:06:23 +01:00
Alberto Geniola e22685640c Bump elmax-api (#133845) 2024-12-30 20:06:22 +01:00
Adam Goode 5756166545 Quickly process unavailable metrics in Prometheus (#133219) 2024-12-30 20:06:21 +01:00
Norbert Rittel 2f8a92c725 Make triggers and condition for monetary sensor consistent (#131184) 2024-12-30 20:06:20 +01:00
Paul Daumlechner cf9ccc6fb4 Bump pyvlx to 0.2.26 (#115483) 2024-12-30 20:06:19 +01:00
Paulus Schoutsen b05b9b9a33 Bump version to 2025.1.0b3 2024-12-29 18:37:17 +00:00
Paulus Schoutsen 352d5d14a3 Bump frontend to 20241229.0 (#134225) 2024-12-29 18:37:04 +00:00
Michael Hansen 52e47f55c8 Bump VoIP utils to 0.2.2 (#134219) 2024-12-29 18:37:03 +00:00
Lucas Gasenzer 0470bff9a2 Fix Wake on LAN Port input as Box instead of Slider (#134216) 2024-12-29 18:37:02 +00:00
Michael a38839b420 Make feedreader recoverable (#134202)
raise ConfigEntryNotReady on connection errors during setup
2024-12-29 18:37:01 +00:00
Michael 394b2be40a Make PEGELONLINE recoverable (#134199) 2024-12-29 18:37:00 +00:00
Matthias Alphart 291dd6dc66 Update knx-frontend to 2024.12.26.233449 (#134184) 2024-12-29 18:36:59 +00:00
G Johansson ef87366346 Add missing device classes in scrape (#134141) 2024-12-29 18:36:57 +00:00
Joost Lekkerkerker bd243f68a4 Bump yt-dlp to 2024.12.23 (#134131) 2024-12-29 18:36:57 +00:00
Aaron Bach 951baa3972 Bump pytile to 2024.12.0 (#134103) 2024-12-29 18:36:56 +00:00
Joost Lekkerkerker 1874eec8b3 Bump python-homeassistant-analytics to 0.8.1 (#134101) 2024-12-29 18:36:55 +00:00
Joost Lekkerkerker 3120a90f26 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 18:36:54 +00:00
Joost Lekkerkerker 7032361bf5 Make google tasks recoverable (#134092) 2024-12-29 18:36:53 +00:00
Matthias Alphart bd786b53ee 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-29 18:36:53 +00:00
Noah Husby f6a9cd38c0 Remove timeout from Russound RIO initialization (#134070) 2024-12-29 18:36:51 +00:00
Aaron Bach 1a909d3a8a Change SimpliSafe websocket reconnection log to DEBUG-level (#134063)
* Change SimpliSafe websocket reconnection log to `DEBUG`-level

* revert
2024-12-29 18:36:51 +00:00
Noah Husby b84ae2abc3 Bump aiorussound to 4.1.1 (#134058)
* Bump aiorussound to 4.1.1

* Trigger Build

* Trigger Build
2024-12-29 18:36:50 +00:00
G Johansson 15b80c59fc Cleanup devices in Nord Pool from reconfiguration (#134043)
* Cleanup devices in Nord Pool from reconfiguration

* Mods

* Mod
2024-12-29 18:36:49 +00:00
G Johansson c11bdcc949 Fix Nord Pool empty response (#134033)
* Fix Nord Pool empty response

* Mods

* reset validate prices
2024-12-29 18:36:48 +00:00
Allen Porter 1957ab1ccf Improve Google Tasks error messages (#134023) 2024-12-29 18:36:47 +00:00
Josef Zweck ef2af44795 Bump pylamarzocco to 1.4.3 (#134008) 2024-12-29 18:36:47 +00:00
J. Nick Koston f0e8360401 Ensure all states have been migrated to use timestamps (#134007) 2024-12-29 18:36:46 +00:00
Cyrill Raccaud 03fb136218 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-29 18:36:45 +00:00
Bram Kragten d415b7bc8d Bump version to 2025.1.0b2 2024-12-24 16:42:54 +01:00
Bram Kragten 9242b67e0d Update frontend to 20241224.0 (#133963) 2024-12-24 16:42:36 +01:00
Marc Mueller 6e7d095831 Update Jinja2 to 3.1.5 (#133951) 2024-12-24 16:42:35 +01:00
Joost Lekkerkerker ef05133a66 Use SignedSession in Xbox (#133938) 2024-12-24 16:42:34 +01:00
Franck Nijhof 7b2fc282e5 Update apprise to v1.9.1 (#133936) 2024-12-24 16:42:33 +01:00
Philipp Danner 4ca17dbb9e fix "Slow" response leads to "Could not find a charging station" #124129 (#133889)
fix #124129
2024-12-24 16:42:32 +01:00
Khole 5d7a22fa76 Hive: Fix error when device goes offline (#133848) 2024-12-24 16:42:31 +01:00
Claudio Ruggeri - CR-Tech 502fbe65ee Fix reload modbus component issue (#133820)
fix issue 116675
2024-12-24 16:42:31 +01:00
Franck Nijhof ce83071900 Bump version to 2025.1.0b1 2024-12-24 08:24:58 +00:00
G-Two 4f1e9b2338 Stop using shared aiohttp client session for Subaru integration (#133931) 2024-12-24 08:24:37 +00:00
Franck Nijhof f23bc51b88 Fix Peblar import in data coordinator (#133926) 2024-12-24 08:24:34 +00:00
Dave T 44150e9fd7 Fix missing % in string for generic camera (#133925)
Fix missing % in generic camera string
2024-12-24 08:24:31 +00:00
Brett Adams cf9686a802 Slow down polling in Teslemetry (#133924) 2024-12-24 08:24:27 +00:00
Abílio Costa 657e5b73b6 Add cronsim to default dependencies (#133913) 2024-12-24 08:24:24 +00:00
J. Nick Koston d3666ecf8a Fix duplicate call to async_register_preload_platform (#133909) 2024-12-24 08:24:21 +00:00
J. Nick Koston bed186cce4 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-24 08:24:18 +00:00
J. Nick Koston 2b8240746a 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-24 08:24:14 +00:00
Martin Mrazik efabb82cb6 Map RGB+CCT to RGB for WLED (#133900) 2024-12-24 08:24:11 +00:00
Jordi 80955ba821 Add Harvey virtual integration (#133874)
Add harvey virtual integration
2024-12-24 08:24:07 +00:00
karwosts bb371c87d5 Fix a history stats bug when window and tracked state change simultaneously (#133770) 2024-12-24 08:24:04 +00:00
Thomas55555 7ce563b0b4 Catch ClientConnectorError and TimeOutError in APSystems (#132027) 2024-12-24 08:24:00 +00:00
Franck Nijhof c2f6e5036e Bump version to 2025.1.0b0 2024-12-23 15:56:12 +00:00
749 changed files with 9059 additions and 38859 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.2"
HA_SHORT_VERSION: "2025.1"
DEFAULT_PYTHON: "3.12"
ALL_PYTHON_VERSIONS: "['3.12', '3.13']"
# 10.3 is the oldest supported version
+4 -33
View File
@@ -76,20 +76,8 @@ 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:
@@ -98,13 +86,6 @@ 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:
@@ -142,11 +123,6 @@ 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:
@@ -166,7 +142,7 @@ jobs:
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev"
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-dev"
skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
@@ -191,11 +167,6 @@ 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:
@@ -234,7 +205,7 @@ jobs:
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
@@ -248,7 +219,7 @@ jobs:
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
@@ -262,7 +233,7 @@ jobs:
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.6
rev: v0.8.3
hooks:
- id: ruff
args:
-4
View File
@@ -311,7 +311,6 @@ homeassistant.components.manual.*
homeassistant.components.mastodon.*
homeassistant.components.matrix.*
homeassistant.components.matter.*
homeassistant.components.mcp_server.*
homeassistant.components.mealie.*
homeassistant.components.media_extractor.*
homeassistant.components.media_player.*
@@ -363,9 +362,7 @@ homeassistant.components.openuv.*
homeassistant.components.oralb.*
homeassistant.components.otbr.*
homeassistant.components.overkiz.*
homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.*
homeassistant.components.pandora.*
homeassistant.components.panel_custom.*
homeassistant.components.peblar.*
homeassistant.components.peco.*
@@ -383,7 +380,6 @@ homeassistant.components.pure_energie.*
homeassistant.components.purpleair.*
homeassistant.components.pushbullet.*
homeassistant.components.pvoutput.*
homeassistant.components.python_script.*
homeassistant.components.qnap_qsw.*
homeassistant.components.rabbitair.*
homeassistant.components.radarr.*
+8 -16
View File
@@ -637,8 +637,6 @@ build.json @home-assistant/supervisor
/tests/components/homeassistant_sky_connect/ @home-assistant/core
/homeassistant/components/homeassistant_yellow/ @home-assistant/core
/tests/components/homeassistant_yellow/ @home-assistant/core
/homeassistant/components/homee/ @Taraman17
/tests/components/homee/ @Taraman17
/homeassistant/components/homekit/ @bdraco
/tests/components/homekit/ @bdraco
/homeassistant/components/homekit_controller/ @Jc2k @bdraco
@@ -688,8 +686,6 @@ build.json @home-assistant/supervisor
/tests/components/icloud/ @Quentame @nzapponi
/homeassistant/components/idasen_desk/ @abmantis
/tests/components/idasen_desk/ @abmantis
/homeassistant/components/igloohome/ @keithle888
/tests/components/igloohome/ @keithle888
/homeassistant/components/ign_sismologia/ @exxamalte
/tests/components/ign_sismologia/ @exxamalte
/homeassistant/components/image/ @home-assistant/core
@@ -891,8 +887,6 @@ build.json @home-assistant/supervisor
/tests/components/matrix/ @PaarthShah
/homeassistant/components/matter/ @home-assistant/matter
/tests/components/matter/ @home-assistant/matter
/homeassistant/components/mcp_server/ @allenporter
/tests/components/mcp_server/ @allenporter
/homeassistant/components/mealie/ @joostlek @andrew-codechimp
/tests/components/mealie/ @joostlek @andrew-codechimp
/homeassistant/components/meater/ @Sotolotl @emontnemery
@@ -1109,10 +1103,8 @@ build.json @home-assistant/supervisor
/tests/components/otbr/ @home-assistant/core
/homeassistant/components/ourgroceries/ @OnFreund
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl
/tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek
/tests/components/overseerr/ @joostlek
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -1143,8 +1135,8 @@ build.json @home-assistant/supervisor
/tests/components/plaato/ @JohNan
/homeassistant/components/plex/ @jjlawren
/tests/components/plex/ @jjlawren
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
/tests/components/plugwise/ @CoMPaTech @bouwew
/homeassistant/components/plugwise/ @CoMPaTech @bouwew @frenck
/tests/components/plugwise/ @CoMPaTech @bouwew @frenck
/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa
/tests/components/plum_lightpad/ @ColinHarrington @prystupa
/homeassistant/components/point/ @fredrike
@@ -1486,8 +1478,8 @@ build.json @home-assistant/supervisor
/tests/components/system_bridge/ @timmo001
/homeassistant/components/systemmonitor/ @gjohansson-ST
/tests/components/systemmonitor/ @gjohansson-ST
/homeassistant/components/tado/ @erwindouna
/tests/components/tado/ @erwindouna
/homeassistant/components/tado/ @chiefdragon @erwindouna
/tests/components/tado/ @chiefdragon @erwindouna
/homeassistant/components/tag/ @balloob @dmulcahey
/tests/components/tag/ @balloob @dmulcahey
/homeassistant/components/tailscale/ @frenck
@@ -1581,8 +1573,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
/tests/components/tuya/ @Tuya @zlinoliver
/homeassistant/components/tuya/ @Tuya @zlinoliver @frenck
/tests/components/tuya/ @Tuya @zlinoliver @frenck
/homeassistant/components/twentemilieu/ @frenck
/tests/components/twentemilieu/ @frenck
/homeassistant/components/twinkly/ @dr1rrb @Robbie1221 @Olen
-1
View File
@@ -2,7 +2,6 @@
"domain": "microsoft",
"name": "Microsoft",
"integrations": [
"azure_data_explorer",
"azure_devops",
"azure_event_hub",
"azure_service_bus",
+5 -5
View File
@@ -34,17 +34,17 @@
"services": {
"capture_image": {
"name": "Capture image",
"description": "Requests a new image capture from a camera device.",
"description": "Request a new image capture from a camera device.",
"fields": {
"entity_id": {
"name": "Entity",
"description": "Entity ID of the camera to request an image from."
"description": "Entity id of the camera to request an image."
}
}
},
"change_setting": {
"name": "Change setting",
"description": "Changes an Abode system setting.",
"description": "Change an Abode system setting.",
"fields": {
"setting": {
"name": "Setting",
@@ -58,11 +58,11 @@
},
"trigger_automation": {
"name": "Trigger automation",
"description": "Triggers an Abode automation.",
"description": "Trigger an Abode automation.",
"fields": {
"entity_id": {
"name": "Entity",
"description": "Entity ID of the automation to trigger."
"description": "Entity id of the automation to trigger."
}
}
}
@@ -39,54 +39,45 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="temp",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
"humidity": SensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
"pressure": SensorEntityDescription(
key="pressure",
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
native_unit_of_measurement=UnitOfPressure.MBAR,
state_class=SensorStateClass.MEASUREMENT,
),
"battery": SensorEntityDescription(
key="battery",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
"co2": SensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
"voc": SensorEntityDescription(
key="voc",
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
state_class=SensorStateClass.MEASUREMENT,
),
"light": SensorEntityDescription(
key="light",
native_unit_of_measurement=PERCENTAGE,
translation_key="light",
state_class=SensorStateClass.MEASUREMENT,
),
"virusRisk": SensorEntityDescription(
key="virusRisk",
translation_key="virus_risk",
state_class=SensorStateClass.MEASUREMENT,
),
"mold": SensorEntityDescription(
key="mold",
translation_key="mold",
state_class=SensorStateClass.MEASUREMENT,
),
"rssi": SensorEntityDescription(
key="rssi",
@@ -94,19 +85,16 @@ SENSORS: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
"pm1": SensorEntityDescription(
key="pm1",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.PM1,
state_class=SensorStateClass.MEASUREMENT,
),
"pm25": SensorEntityDescription(
key="pm25",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
),
}
@@ -21,7 +21,7 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"invalid_unique_id": "Impossible to determine a valid unique ID for the device"
"invalid_unique_id": "Impossible to determine a valid unique id for the device"
}
},
"options": {
@@ -38,17 +38,17 @@
}
},
"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_delete": "Check to delete this application"
}
},
"rules": {
"title": "Configure Android state detection rules",
"description": "Configure detection rule for application ID {rule_id}",
"description": "Configure detection rule for application id {rule_id}",
"data": {
"rule_id": "[%key:component::androidtv::options::step::apps::data::app_id%]",
"rule_values": "List of state detection rules (see documentation)",
@@ -29,8 +29,6 @@ class ApSystemsSensorData:
class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
"""Coordinator used for all sensors."""
device_version: str
def __init__(self, hass: HomeAssistant, api: APsystemsEZ1M) -> None:
"""Initialize my coordinator."""
super().__init__(
@@ -48,7 +46,6 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
raise UpdateFailed from None
self.api.max_power = device_info.maxPower
self.api.min_power = device_info.minPower
self.device_version = device_info.devVer
async def _async_update_data(self) -> ApSystemsSensorData:
try:
+1 -2
View File
@@ -21,8 +21,7 @@ class ApSystemsEntity(Entity):
"""Initialize the APsystems entity."""
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data.device_id)},
serial_number=data.device_id,
manufacturer="APsystems",
model="EZ1-M",
serial_number=data.device_id,
sw_version=data.coordinator.device_version.split(" ")[1],
)
@@ -19,5 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/aranet",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aranet4==2.5.0"]
"requirements": ["aranet4==2.4.0"]
}
@@ -22,7 +22,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_NAME,
ATTR_SW_VERSION,
CONCENTRATION_PARTS_PER_MILLION,
@@ -143,7 +142,6 @@ def _sensor_device_info_to_hass(
if adv.readings and adv.readings.name:
hass_device_info[ATTR_NAME] = adv.readings.name
hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME
hass_device_info[ATTR_MODEL] = adv.readings.type.model
if adv.manufacturer_data:
hass_device_info[ATTR_SW_VERSION] = str(adv.manufacturer_data.version)
return hass_device_info
@@ -90,7 +90,7 @@ class ArubaDeviceScanner(DeviceScanner):
"""Retrieve data from Aruba Access Point and return parsed result."""
connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa"
ssh: pexpect.spawn[str] = pexpect.spawn(connect, encoding="utf-8")
ssh = pexpect.spawn(connect)
query = ssh.expect(
[
"password:",
@@ -125,12 +125,12 @@ class ArubaDeviceScanner(DeviceScanner):
ssh.expect("#")
ssh.sendline("show clients")
ssh.expect("#")
devices_result = (ssh.before or "").splitlines()
devices_result = ssh.before.split(b"\r\n")
ssh.sendline("exit")
devices: dict[str, dict[str, str]] = {}
for device in devices_result:
if match := _DEVICES_REGEX.search(device):
if match := _DEVICES_REGEX.search(device.decode("utf-8")):
devices[match.group("ip")] = {
"ip": match.group("ip"),
"mac": match.group("mac").upper(),
+1 -1
View File
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pexpect", "ptyprocess"],
"quality_scale": "legacy",
"requirements": ["pexpect==4.9.0"]
"requirements": ["pexpect==4.6.0"]
}
@@ -108,7 +108,6 @@ async def async_pipeline_from_audio_stream(
device_id: str | None = None,
start_stage: PipelineStage = PipelineStage.STT,
end_stage: PipelineStage = PipelineStage.TTS,
conversation_extra_system_prompt: str | None = None,
) -> None:
"""Create an audio pipeline from an audio stream.
@@ -120,7 +119,6 @@ async def async_pipeline_from_audio_stream(
stt_metadata=stt_metadata,
stt_stream=stt_stream,
wake_word_phrase=wake_word_phrase,
conversation_extra_system_prompt=conversation_extra_system_prompt,
run=PipelineRun(
hass,
context=context,
@@ -1010,11 +1010,7 @@ class PipelineRun:
self.intent_agent = agent_info.id
async def recognize_intent(
self,
intent_input: str,
conversation_id: str | None,
device_id: str | None,
conversation_extra_system_prompt: str | None,
self, intent_input: str, conversation_id: str | None, device_id: str | None
) -> str:
"""Run intent recognition portion of pipeline. Returns text to speak."""
if self.intent_agent is None:
@@ -1049,7 +1045,6 @@ class PipelineRun:
device_id=device_id,
language=input_language,
agent_id=self.intent_agent,
extra_system_prompt=conversation_extra_system_prompt,
)
processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT
@@ -1397,13 +1392,8 @@ class PipelineInput:
"""Input for text-to-speech. Required when start_stage = tts."""
conversation_id: str | None = None
"""Identifier for the conversation."""
conversation_extra_system_prompt: str | None = None
"""Extra prompt information for the conversation agent."""
device_id: str | None = None
"""Identifier of the device that is processing the input/output of the pipeline."""
async def execute(self) -> None:
"""Run pipeline."""
@@ -1493,7 +1483,6 @@ class PipelineInput:
intent_input,
self.conversation_id,
self.device_id,
self.conversation_extra_system_prompt,
)
if tts_input.strip():
current_stage = PipelineStage.TTS
@@ -34,7 +34,7 @@ class BangOlufsenData:
type BangOlufsenConfigEntry = ConfigEntry[BangOlufsenData]
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) -> bool:
@@ -79,7 +79,6 @@ class WebsocketNotification(StrEnum):
"""Enum for WebSocket notification types."""
ACTIVE_LISTENING_MODE = "active_listening_mode"
BUTTON = "button"
PLAYBACK_ERROR = "playback_error"
PLAYBACK_METADATA = "playback_metadata"
PLAYBACK_PROGRESS = "playback_progress"
@@ -204,60 +203,14 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
),
]
)
# Map for storing compatibility of devices.
MODEL_SUPPORT_DEVICE_BUTTONS: Final[str] = "device_buttons"
MODEL_SUPPORT_MAP = {
MODEL_SUPPORT_DEVICE_BUTTONS: (
BangOlufsenModel.BEOLAB_8,
BangOlufsenModel.BEOLAB_28,
BangOlufsenModel.BEOSOUND_2,
BangOlufsenModel.BEOSOUND_A5,
BangOlufsenModel.BEOSOUND_A9,
BangOlufsenModel.BEOSOUND_BALANCE,
BangOlufsenModel.BEOSOUND_EMERGE,
BangOlufsenModel.BEOSOUND_LEVEL,
BangOlufsenModel.BEOSOUND_THEATRE,
)
}
# Device events
BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
# Dict used to translate native Bang & Olufsen event names to string.json compatible ones
EVENT_TRANSLATION_MAP: dict[str, str] = {
"shortPress (Release)": "short_press_release",
"longPress (Timeout)": "long_press_timeout",
"longPress (Release)": "long_press_release",
"veryLongPress (Timeout)": "very_long_press_timeout",
"veryLongPress (Release)": "very_long_press_release",
}
CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS"
DEVICE_BUTTONS: Final[list[str]] = [
"Bluetooth",
"Microphone",
"Next",
"PlayPause",
"Preset1",
"Preset2",
"Preset3",
"Preset4",
"Previous",
"Volume",
]
DEVICE_BUTTON_EVENTS: Final[list[str]] = [
"short_press_release",
"long_press_timeout",
"long_press_release",
"very_long_press_timeout",
"very_long_press_release",
]
# Beolink Converter NL/ML sources need to be transformed to upper case
BEOLINK_JOIN_SOURCES_TO_UPPER = (
"aux_a",
@@ -1,76 +0,0 @@
"""Event entities for the Bang & Olufsen integration."""
from __future__ import annotations
from homeassistant.components.event import EventDeviceClass, EventEntity
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import BangOlufsenConfigEntry
from .const import (
CONNECTION_STATUS,
DEVICE_BUTTON_EVENTS,
DEVICE_BUTTONS,
MODEL_SUPPORT_DEVICE_BUTTONS,
MODEL_SUPPORT_MAP,
WebsocketNotification,
)
from .entity import BangOlufsenEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BangOlufsenConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Sensor entities from config entry."""
if config_entry.data[CONF_MODEL] in MODEL_SUPPORT_MAP[MODEL_SUPPORT_DEVICE_BUTTONS]:
async_add_entities(
BangOlufsenButtonEvent(config_entry, button_type)
for button_type in DEVICE_BUTTONS
)
class BangOlufsenButtonEvent(BangOlufsenEntity, EventEntity):
"""Event class for Button events."""
_attr_device_class = EventDeviceClass.BUTTON
_attr_entity_registry_enabled_default = False
_attr_event_types = DEVICE_BUTTON_EVENTS
def __init__(self, config_entry: BangOlufsenConfigEntry, button_type: str) -> None:
"""Initialize Button."""
super().__init__(config_entry, config_entry.runtime_data.client)
self._attr_unique_id = f"{self._unique_id}_{button_type}"
# Make the native button name Home Assistant compatible
self._attr_translation_key = button_type.lower()
self._button_type = button_type
async def async_added_to_hass(self) -> None:
"""Listen to WebSocket button events."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{CONNECTION_STATUS}",
self._async_update_connection_state,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WebsocketNotification.BUTTON}_{self._button_type}",
self._async_handle_event,
)
)
@callback
def _async_handle_event(self, event: str) -> None:
"""Handle event."""
self._trigger_event(event)
self.async_write_ha_state()
@@ -1,12 +1,7 @@
{
"common": {
"jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity.",
"jid_options_name": "JID options",
"long_press_release": "Release of long press",
"long_press_timeout": "Long press",
"short_press_release": "Release of short press",
"very_long_press_release": "Release of very long press",
"very_long_press_timeout": "Very long press"
"jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity."
},
"config": {
"error": {
@@ -34,150 +29,6 @@
}
}
},
"entity": {
"event": {
"bluetooth": {
"name": "Bluetooth",
"state_attributes": {
"event_type": {
"state": {
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
}
}
}
},
"microphone": {
"name": "Microphone",
"state_attributes": {
"event_type": {
"state": {
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
}
}
}
},
"next": {
"name": "Next",
"state_attributes": {
"event_type": {
"state": {
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
}
}
}
},
"playpause": {
"name": "Play / Pause",
"state_attributes": {
"event_type": {
"state": {
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
}
}
}
},
"preset1": {
"name": "Favourite 1",
"state_attributes": {
"event_type": {
"state": {
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
}
}
}
},
"preset2": {
"name": "Favourite 2",
"state_attributes": {
"event_type": {
"state": {
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
}
}
}
},
"preset3": {
"name": "Favourite 3",
"state_attributes": {
"event_type": {
"state": {
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
}
}
}
},
"preset4": {
"name": "Favourite 4",
"state_attributes": {
"event_type": {
"state": {
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
}
}
}
},
"previous": {
"name": "Previous",
"state_attributes": {
"event_type": {
"state": {
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
}
}
}
},
"volume": {
"name": "Volume",
"state_attributes": {
"event_type": {
"state": {
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
}
}
}
}
}
},
"selector": {
"source_ids": {
"options": {
@@ -3,10 +3,8 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from mozart_api.models import (
ButtonEvent,
ListeningModeProps,
PlaybackContentMetadata,
PlaybackError,
@@ -28,7 +26,6 @@ from homeassistant.util.enum import try_parse_enum
from .const import (
BANG_OLUFSEN_WEBSOCKET_EVENT,
CONNECTION_STATUS,
EVENT_TRANSLATION_MAP,
WebsocketNotification,
)
from .entity import BangOlufsenBase
@@ -57,8 +54,6 @@ class BangOlufsenWebsocket(BangOlufsenBase):
self._client.get_active_listening_mode_notifications(
self.on_active_listening_mode
)
self._client.get_button_notifications(self.on_button_notification)
self._client.get_playback_error_notifications(
self.on_playback_error_notification
)
@@ -109,19 +104,6 @@ class BangOlufsenWebsocket(BangOlufsenBase):
notification,
)
def on_button_notification(self, notification: ButtonEvent) -> None:
"""Send button dispatch."""
# State is expected to always be available.
if TYPE_CHECKING:
assert notification.state
# Send to event entity
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.BUTTON}_{notification.button}",
EVENT_TRANSLATION_MAP[notification.state],
)
def on_notification_notification(
self, notification: WebsocketNotificationTag
) -> None:
@@ -71,6 +71,27 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
),
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import bluesound config entry from configuration.yaml."""
session = async_get_clientsession(self.hass)
async with Player(
import_data[CONF_HOST], import_data[CONF_PORT], session=session
) as player:
try:
sync_status = await player.sync_status(timeout=1)
except PlayerUnreachableError:
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(
format_unique_id(sync_status.mac, import_data[CONF_PORT])
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=sync_status.name,
data=import_data,
)
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
@@ -15,6 +15,7 @@ import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA,
BrowseMedia,
MediaPlayerEntity,
MediaPlayerEntityFeature,
@@ -22,10 +23,16 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers import (
config_validation as cv,
entity_platform,
issue_registry as ir,
)
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
@@ -36,9 +43,10 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN, INTEGRATION_TITLE
from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id
if TYPE_CHECKING:
@@ -63,6 +71,64 @@ SYNC_STATUS_INTERVAL = timedelta(minutes=5)
POLL_TIMEOUT = 120
PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_HOSTS): vol.All(
cv.ensure_list,
[
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
}
],
)
}
)
async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
"""Import config entry from configuration.yaml."""
if not hass.config_entries.async_entries(DOMAIN):
# Start import flow
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
if (
result["type"] == FlowResultType.ABORT
and result["reason"] == "cannot_connect"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result['reason']}",
breaks_in_ha_version="2025.2.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
return
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2025.2.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -93,6 +159,22 @@ async def async_setup_entry(
async_add_entities([bluesound_player], update_before_add=True)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None,
) -> None:
"""Trigger import flows."""
hosts = config.get(CONF_HOSTS, [])
for host in hosts:
import_data = {
CONF_HOST: host[CONF_HOST],
CONF_PORT: host.get(CONF_PORT, 11000),
}
hass.async_create_task(_async_import(hass, import_data))
class BluesoundPlayer(MediaPlayerEntity):
"""Representation of a Bluesound Player."""
+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, ClassVar
from typing import TYPE_CHECKING
from urllib.parse import urlparse
import aiohttp
@@ -129,7 +129,7 @@ class ChromecastInfo:
class ChromeCastZeroconf:
"""Class to hold a zeroconf instance."""
__zconf: ClassVar[zeroconf.HaZeroconf | None] = None
__zconf: zeroconf.HaZeroconf | None = None
@classmethod
def set_zeroconf(cls, zconf: zeroconf.HaZeroconf) -> None:
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import re
from pexpect import pxssh
import voluptuous as vol
@@ -100,11 +101,11 @@ class CiscoDeviceScanner(DeviceScanner):
return False
def _get_arp_data(self) -> str | None:
def _get_arp_data(self):
"""Open connection to the router and get arp entries."""
try:
cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="uft-8")
cisco_ssh = pxssh.pxssh()
cisco_ssh.login(
self.host,
self.username,
@@ -114,11 +115,12 @@ class CiscoDeviceScanner(DeviceScanner):
)
# Find the hostname
initial_line = (cisco_ssh.before or "").splitlines()
initial_line = cisco_ssh.before.decode("utf-8").splitlines()
router_hostname = initial_line[len(initial_line) - 1]
router_hostname += "#"
# Set the discovered hostname as prompt
cisco_ssh.PROMPT = f"(?i)^{router_hostname}"
regex_expression = f"(?i)^{router_hostname}".encode()
cisco_ssh.PROMPT = re.compile(regex_expression, re.MULTILINE)
# Allow full arp table to print at once
cisco_ssh.sendline("terminal length 0")
cisco_ssh.prompt(1)
@@ -126,11 +128,13 @@ class CiscoDeviceScanner(DeviceScanner):
cisco_ssh.sendline("show ip arp")
cisco_ssh.prompt(1)
devices_result = cisco_ssh.before
return devices_result.decode("utf-8")
except pxssh.ExceptionPxssh as px_e:
_LOGGER.error("Failed to login via pxssh: %s", px_e)
return None
return cisco_ssh.before
return None
def _parse_cisco_mac_address(cisco_hardware_addr):
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pexpect", "ptyprocess"],
"quality_scale": "legacy",
"requirements": ["pexpect==4.9.0"]
"requirements": ["pexpect==4.6.0"]
}
+27 -3
View File
@@ -6,9 +6,9 @@ import base64
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import hashlib
import logging
from typing import Any
from typing import Any, Self
from aiohttp import ClientError, ClientTimeout
from aiohttp import ClientError, ClientTimeout, StreamReader
from hass_nabucasa import Cloud, CloudError
from hass_nabucasa.cloud_api import (
async_files_delete_file,
@@ -19,7 +19,6 @@ from hass_nabucasa.cloud_api import (
from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .client import CloudClient
@@ -74,6 +73,31 @@ def async_register_backup_agents_listener(
return unsub
class ChunkAsyncStreamIterator:
"""Async iterator for chunked streams.
Based on aiohttp.streams.ChunkTupleAsyncStreamIterator, but yields
bytes instead of tuple[bytes, bool].
"""
__slots__ = ("_stream",)
def __init__(self, stream: StreamReader) -> None:
"""Initialize."""
self._stream = stream
def __aiter__(self) -> Self:
"""Iterate."""
return self
async def __anext__(self) -> bytes:
"""Yield next chunk."""
rv = await self._stream.readchunk()
if rv == (b"", False):
raise StopAsyncIteration
return rv[0]
class CloudBackupAgent(BackupAgent):
"""Cloud backup agent."""
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/compensation",
"iot_class": "calculated",
"quality_scale": "legacy",
"requirements": ["numpy==2.2.1"]
"requirements": ["numpy==2.2.0"]
}
@@ -46,13 +46,6 @@ def async_setup(hass: HomeAssistant) -> bool:
hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options))
hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options))
hass.http.register_view(
SubentryManagerFlowIndexView(hass.config_entries.subentries)
)
hass.http.register_view(
SubentryManagerFlowResourceView(hass.config_entries.subentries)
)
websocket_api.async_register_command(hass, config_entries_get)
websocket_api.async_register_command(hass, config_entry_disable)
websocket_api.async_register_command(hass, config_entry_get_single)
@@ -61,9 +54,6 @@ def async_setup(hass: HomeAssistant) -> bool:
websocket_api.async_register_command(hass, config_entries_progress)
websocket_api.async_register_command(hass, ignore_config_flow)
websocket_api.async_register_command(hass, config_subentry_delete)
websocket_api.async_register_command(hass, config_subentry_list)
return True
@@ -295,66 +285,6 @@ class OptionManagerFlowResourceView(
return await super().post(request, flow_id)
class SubentryManagerFlowIndexView(
FlowManagerIndexView[config_entries.ConfigSubentryFlowManager]
):
"""View to create subentry flows."""
url = "/api/config/config_entries/subentries/flow"
name = "api:config:config_entries:subentries:flow"
@require_admin(
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
@RequestDataValidator(
vol.Schema(
{
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
vol.Optional("show_advanced_options", default=False): cv.boolean,
},
extra=vol.ALLOW_EXTRA,
)
)
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle a POST request.
handler in request is [entry_id, subentry_type].
"""
return await super()._post_impl(request, data)
def get_context(self, data: dict[str, Any]) -> dict[str, Any]:
"""Return context."""
context = super().get_context(data)
context["source"] = config_entries.SOURCE_USER
if subentry_id := data.get("subentry_id"):
context["source"] = config_entries.SOURCE_RECONFIGURE
context["subentry_id"] = subentry_id
return context
class SubentryManagerFlowResourceView(
FlowManagerResourceView[config_entries.ConfigSubentryFlowManager]
):
"""View to interact with the subentry flow manager."""
url = "/api/config/config_entries/subentries/flow/{flow_id}"
name = "api:config:config_entries:subentries:flow:resource"
@require_admin(
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
async def get(self, request: web.Request, /, flow_id: str) -> web.Response:
"""Get the current state of a data_entry_flow."""
return await super().get(request, flow_id)
@require_admin(
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
async def post(self, request: web.Request, flow_id: str) -> web.Response:
"""Handle a POST request."""
return await super().post(request, flow_id)
@websocket_api.require_admin
@websocket_api.websocket_command({"type": "config_entries/flow/progress"})
def config_entries_progress(
@@ -658,63 +588,3 @@ async def _async_matching_config_entries_json_fragments(
)
or (filter_is_not_helper and entry.domain not in integrations)
]
@websocket_api.require_admin
@websocket_api.websocket_command(
{
"type": "config_entries/subentries/list",
"entry_id": str,
}
)
@websocket_api.async_response
async def config_subentry_list(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List subentries of a config entry."""
entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
if entry is None:
return
result = [
{
"subentry_id": subentry.subentry_id,
"subentry_type": subentry.subentry_type,
"title": subentry.title,
"unique_id": subentry.unique_id,
}
for subentry in entry.subentries.values()
]
connection.send_result(msg["id"], result)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
"type": "config_entries/subentries/delete",
"entry_id": str,
"subentry_id": str,
}
)
@websocket_api.async_response
async def config_subentry_delete(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Delete a subentry of a config entry."""
entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
if entry is None:
return
try:
hass.config_entries.async_remove_subentry(entry, msg["subentry_id"])
except config_entries.UnknownSubEntry:
connection.send_error(
msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config subentry not found"
)
return
connection.send_result(msg["id"])
@@ -75,7 +75,6 @@ async def async_converse(
language: str | None = None,
agent_id: str | None = None,
device_id: str | None = None,
extra_system_prompt: str | None = None,
) -> ConversationResult:
"""Process text and get intent."""
agent = async_get_agent(hass, agent_id)
@@ -100,7 +99,6 @@ async def async_converse(
device_id=device_id,
language=language,
agent_id=agent_id,
extra_system_prompt=extra_system_prompt,
)
with async_conversation_trace() as trace:
trace.add_event(
@@ -40,9 +40,6 @@ class ConversationInput:
agent_id: str | None = None
"""Agent to use for processing."""
extra_system_prompt: str | None = None
"""Extra prompt to provide extra info to LLMs how to understand the command."""
def as_dict(self) -> dict[str, Any]:
"""Return input as a dict."""
return {
@@ -52,7 +49,6 @@ class ConversationInput:
"device_id": self.device_id,
"language": self.language,
"agent_id": self.agent_id,
"extra_system_prompt": self.extra_system_prompt,
}
@@ -16,7 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO]
PLATFORMS: list[Platform] = [Platform.TODO]
async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool:
@@ -1,70 +0,0 @@
"""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()
@@ -1,10 +1,5 @@
{
"entity": {
"button": {
"todo_clear": {
"default": "mdi:cart-off"
}
},
"todo": {
"ingredient_list": {
"default": "mdi:cart-plus"
@@ -48,11 +48,6 @@
}
},
"entity": {
"button": {
"todo_clear": {
"name": "Clear shopping list and additional purchases"
}
},
"todo": {
"ingredient_list": {
"name": "Shopping list"
@@ -63,9 +58,6 @@
}
},
"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"
},
@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Sequence
import os
from serial.tools.list_ports_common import ListPortInfo
@@ -13,7 +12,7 @@ from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST
def list_ports_as_str(
serial_ports: Sequence[ListPortInfo], no_usb_option: bool = True
serial_ports: list[ListPortInfo], no_usb_option: bool = True
) -> list[str]:
"""Represent currently available serial ports as string.
@@ -1 +0,0 @@
"""Virtual integration: Decorquip."""
@@ -1,6 +0,0 @@
{
"domain": "decorquip",
"name": "Decorquip Dream",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}
@@ -50,7 +50,7 @@
"services": {
"get_command": {
"name": "Get command",
"description": "Sends a generic HTTP get command.",
"description": "Send sa generic HTTP get command.",
"fields": {
"command": {
"name": "Command",
+1 -1
View File
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pydoods"],
"quality_scale": "legacy",
"requirements": ["pydoods==1.0.2", "Pillow==11.1.0"]
"requirements": ["pydoods==1.0.2", "Pillow==11.0.0"]
}
@@ -57,11 +57,11 @@
"services": {
"get_gas_prices": {
"name": "Get gas prices",
"description": "Requests gas prices from easyEnergy.",
"description": "Request gas prices from easyEnergy.",
"fields": {
"config_entry": {
"name": "Config Entry",
"description": "The configuration entry to use for this action."
"description": "The config entry to use for this service."
},
"incl_vat": {
"name": "VAT Included",
@@ -79,7 +79,7 @@
},
"get_energy_usage_prices": {
"name": "Get energy usage prices",
"description": "Requests usage energy prices from easyEnergy.",
"description": "Request 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": "Requests return energy prices from easyEnergy.",
"description": "Request return energy prices from easyEnergy.",
"fields": {
"config_entry": {
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]",
@@ -163,6 +163,11 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
data: dict[str, Any] = {}
data[ATTR_ERROR] = self.error
# these attributes are deprecated and can be removed in 2025.2
for key, val in self.device.components.items():
attr_name = ATTR_COMPONENT_PREFIX + key
data[attr_name] = int(val * 100)
return data
def return_to_base(self, **kwargs: Any) -> None:
@@ -13,7 +13,7 @@ rules:
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-removal-instructions: todo
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: Element, changeset: dict[str, Any]) -> None:
def _element_changed(self, _: Element, changeset: 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: Element, changeset: dict[str, Any]) -> None:
def _element_changed(self, _: Element, changeset: 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: Element, changeset: dict[str, Any]) -> None:
def _element_changed(self, _: Element, changeset: 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: Element, changeset: dict[str, Any]) -> None:
def _element_changed(self, _: Element, changeset: 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: Element, changeset: dict[str, Any]) -> None:
def _element_changed(self, _: Element, changeset: 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: Element, changeset: dict[str, Any]) -> None:
def _element_changed(self, _: Element, changeset: Any) -> None:
if self._element.definition == ZoneType.TEMPERATURE:
self._attr_native_value = temperature_to_state(
self._element.temperature, UNDEFINED_TEMPERATURE
+1 -25
View File
@@ -11,7 +11,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from homeassistant.helpers.entity_registry import async_migrate_entries
from .config_flow import DEFAULT_RTSP_PORT
from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET
@@ -36,9 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
# Migrate to correct unique IDs for switches
await async_migrate_entities(hass, entry)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -95,24 +92,3 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
LOGGER.debug("Migration to version %s successful", entry.version)
return True
async def async_migrate_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Migrate old entry."""
@callback
def _update_unique_id(
entity_entry: RegistryEntry,
) -> dict[str, str] | None:
"""Update unique ID of entity entry."""
if (
entity_entry.domain == Platform.SWITCH
and entity_entry.unique_id == "sleep_switch"
):
entity_new_unique_id = f"{entity_entry.config_entry_id}_sleep_switch"
return {"new_unique_id": entity_new_unique_id}
return None
# Migrate entities
await async_migrate_entries(hass, entry.entry_id, _update_unique_id)
+1 -1
View File
@@ -41,7 +41,7 @@ class FoscamSleepSwitch(FoscamEntity, SwitchEntity):
"""Initialize a Foscam Sleep Switch."""
super().__init__(coordinator, config_entry.entry_id)
self._attr_unique_id = f"{config_entry.entry_id}_sleep_switch"
self._attr_unique_id = "sleep_switch"
self._attr_translation_key = "sleep_switch"
self._attr_has_entity_name = True
+28 -12
View File
@@ -171,8 +171,6 @@ async def async_test_still(
"""Verify that the still image is valid before we create an entity."""
fmt = None
if not (url := info.get(CONF_STILL_IMAGE_URL)):
# If user didn't specify a still image URL,the automatically generated
# still image that stream generates is always jpeg.
return {}, info.get(CONF_CONTENT_TYPE, "image/jpeg")
try:
if not isinstance(url, template_helper.Template):
@@ -257,6 +255,10 @@ 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)
@@ -292,6 +294,8 @@ 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:
@@ -311,8 +315,8 @@ async def async_test_and_preview_stream(
return stream
def register_still_preview(hass: HomeAssistant) -> None:
"""Set up still image preview for camera feeds during config flow."""
def register_preview(hass: HomeAssistant) -> None:
"""Set up previews for camera feeds during config flow."""
hass.data.setdefault(DOMAIN, {})
if not hass.data[DOMAIN].get(IMAGE_PREVIEWS_ACTIVE):
@@ -328,7 +332,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize Generic ConfigFlow."""
self.preview_image_settings: dict[str, Any] = {}
self.preview_cam: dict[str, Any] = {}
self.preview_stream: Stream | None = None
self.user_input: dict[str, Any] = {}
self.title = ""
@@ -368,10 +372,15 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
name = (
slug(hass, still_url) or slug(hass, stream_url) or DEFAULT_NAME
)
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
user_input[CONF_CONTENT_TYPE] = "image/jpeg"
self.user_input = user_input
self.title = name
# temporary preview for user to check the image
self.preview_image_settings = user_input
self.preview_cam = user_input
return await self.async_step_user_confirm()
elif self.user_input:
user_input = self.user_input
@@ -396,7 +405,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=self.title, data={}, options=self.user_input
)
register_still_preview(self.hass)
register_preview(self.hass)
return self.async_show_form(
step_id="user_confirm",
data_schema=vol.Schema(
@@ -419,7 +428,7 @@ class GenericOptionsFlowHandler(OptionsFlow):
def __init__(self) -> None:
"""Initialize Generic IP Camera options flow."""
self.preview_image_settings: dict[str, Any] = {}
self.preview_cam: dict[str, Any] = {}
self.preview_stream: Stream | None = None
self.user_input: dict[str, Any] = {}
@@ -446,6 +455,13 @@ class GenericOptionsFlowHandler(OptionsFlow):
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
@@ -456,7 +472,7 @@ class GenericOptionsFlowHandler(OptionsFlow):
}
self.user_input = data
# temporary preview for user to check the image
self.preview_image_settings = data
self.preview_cam = data
return await self.async_step_user_confirm()
elif self.user_input:
user_input = self.user_input
@@ -484,7 +500,7 @@ class GenericOptionsFlowHandler(OptionsFlow):
title=self.config_entry.title,
data=self.user_input,
)
register_still_preview(self.hass)
register_preview(self.hass)
return self.async_show_form(
step_id="user_confirm",
data_schema=vol.Schema(
@@ -526,7 +542,7 @@ class CameraImagePreview(HomeAssistantView):
if not flow:
_LOGGER.warning("Unknown flow while getting image preview")
raise web.HTTPNotFound
user_input = flow.preview_image_settings
user_input = flow.preview_cam
camera = GenericCamera(self.hass, user_input, flow_id, "preview")
if not camera.is_on:
_LOGGER.debug("Camera is off")
@@ -567,7 +583,7 @@ async def ws_start_preview(
GenericOptionsFlowHandler,
hass.config_entries.options._progress.get(flow_id), # noqa: SLF001
)
user_input = flow.preview_image_settings
user_input = flow.preview_cam
# Create an EntityPlatform, needed for name translations
platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN)
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["av==13.1.0", "Pillow==11.1.0"]
"requirements": ["av==13.1.0", "Pillow==11.0.0"]
}
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==8.3.0"]
"requirements": ["gcal-sync==6.2.0", "oauth2client==4.1.3", "ical==8.2.0"]
}
@@ -13,7 +13,7 @@
"fields": {
"agent_user_id": {
"name": "Agent user ID",
"description": "Only needed for automations. Specific Home Assistant user ID (not username, ID in Settings > People > Users > under username) to sync with Google Assistant. Not needed when you use this action through Home Assistant frontend or API. Used in automation, script or other place where context.user_id is missing."
"description": "Only needed for automations. Specific Home Assistant user id (not username, ID in configuration > users > under username) to sync with Google Assistant. Do not need when you use this action through Home Assistant front end or API. Used in automation script or other place where context.user_id is missing."
}
}
}
@@ -66,11 +66,11 @@
"services": {
"upload": {
"name": "Upload media",
"description": "Uploads images or videos to Google Photos.",
"description": "Upload images or videos to Google Photos.",
"fields": {
"config_entry_id": {
"name": "Integration ID",
"description": "The Google Photos integration ID."
"name": "Integration Id",
"description": "The Google Photos integration id."
},
"filename": {
"name": "Filename",
+1 -1
View File
@@ -238,7 +238,7 @@
},
"set": {
"name": "Set",
"description": "Creates/Updates a group.",
"description": "Creates/Updates a user group.",
"fields": {
"object_id": {
"name": "Object ID",
+52 -11
View File
@@ -1,15 +1,27 @@
"""The habitica integration."""
from habiticalib import Habitica
from http import HTTPStatus
from aiohttp import ClientResponseError
from habitipy.aio import HabitipyAsync
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.const import (
APPLICATION_NAME,
CONF_API_KEY,
CONF_NAME,
CONF_URL,
CONF_VERIFY_SSL,
Platform,
__version__,
)
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, DOMAIN, X_CLIENT
from .const import CONF_API_USER, DEVELOPER_ID, DOMAIN
from .coordinator import HabiticaDataUpdateCoordinator
from .services import async_setup_services
from .types import HabiticaConfigEntry
@@ -21,7 +33,6 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CALENDAR,
Platform.IMAGE,
Platform.SENSOR,
Platform.SWITCH,
Platform.TODO,
@@ -40,17 +51,47 @@ async def async_setup_entry(
) -> bool:
"""Set up habitica from a config entry."""
session = async_get_clientsession(
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(
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
)
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,
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],
},
)
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,8 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from habiticalib import UserData
from typing import Any
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
@@ -24,8 +23,8 @@ from .types import HabiticaConfigEntry
class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Habitica Binary Sensor Description."""
value_fn: Callable[[UserData], bool | None]
entity_picture: Callable[[UserData], str | None]
value_fn: Callable[[dict[str, Any]], bool | None]
entity_picture: Callable[[dict[str, Any]], str | None]
class HabiticaBinarySensor(StrEnum):
@@ -34,10 +33,10 @@ class HabiticaBinarySensor(StrEnum):
PENDING_QUEST = "pending_quest"
def get_scroll_image_for_pending_quest_invitation(user: UserData) -> str | None:
def get_scroll_image_for_pending_quest_invitation(user: dict[str, Any]) -> str | None:
"""Entity picture for pending quest invitation."""
if user.party.quest.key and user.party.quest.RSVPNeeded:
return f"inventory_quest_scroll_{user.party.quest.key}.png"
if user["party"]["quest"].get("key") and user["party"]["quest"]["RSVPNeeded"]:
return f"inventory_quest_scroll_{user["party"]["quest"]["key"]}.png"
return None
@@ -45,7 +44,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,
),
)
+125 -134
View File
@@ -5,17 +5,10 @@ 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 ClientError
from habiticalib import (
HabiticaClass,
HabiticaException,
NotAuthorizedError,
Skill,
TaskType,
TooManyRequestsError,
)
from aiohttp import ClientResponseError
from homeassistant.components.button import (
DOMAIN as BUTTON_DOMAIN,
@@ -27,7 +20,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
from .const import ASSETS_URL, DOMAIN, HEALER, MAGE, ROGUE, WARRIOR
from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
@@ -41,11 +34,11 @@ class HabiticaButtonEntityDescription(ButtonEntityDescription):
press_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
available_fn: Callable[[HabiticaData], bool]
class_needed: HabiticaClass | None = None
class_needed: str | None = None
entity_picture: str | None = None
class HabiticaButtonEntity(StrEnum):
class HabitipyButtonEntity(StrEnum):
"""Habitica button entities."""
RUN_CRON = "run_cron"
@@ -68,207 +61,205 @@ class HabiticaButtonEntity(StrEnum):
BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.RUN_CRON,
translation_key=HabiticaButtonEntity.RUN_CRON,
press_fn=lambda coordinator: coordinator.habitica.run_cron(),
available_fn=lambda data: data.user.needsCron is True,
key=HabitipyButtonEntity.RUN_CRON,
translation_key=HabitipyButtonEntity.RUN_CRON,
press_fn=lambda coordinator: coordinator.api.cron.post(),
available_fn=lambda data: data.user["needsCron"],
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.BUY_HEALTH_POTION,
translation_key=HabiticaButtonEntity.BUY_HEALTH_POTION,
press_fn=lambda coordinator: coordinator.habitica.buy_health_potion(),
key=HabitipyButtonEntity.BUY_HEALTH_POTION,
translation_key=HabitipyButtonEntity.BUY_HEALTH_POTION,
press_fn=(
lambda coordinator: coordinator.api["user"]["buy-health-potion"].post()
),
available_fn=(
lambda data: (data.user.stats.gp or 0) >= 25
and (data.user.stats.hp or 0) < 50
lambda data: data.user["stats"]["gp"] >= 25
and data.user["stats"]["hp"] < 50
),
entity_picture="shop_potion.png",
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS,
translation_key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS,
press_fn=lambda coordinator: coordinator.habitica.allocate_stat_points(),
key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS,
translation_key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS,
press_fn=lambda coordinator: coordinator.api["user"]["allocate-now"].post(),
available_fn=(
lambda data: data.user.preferences.automaticAllocation is True
and (data.user.stats.points or 0) > 0
lambda data: data.user["preferences"].get("automaticAllocation") is True
and data.user["stats"]["points"] > 0
),
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.REVIVE,
translation_key=HabiticaButtonEntity.REVIVE,
press_fn=lambda coordinator: coordinator.habitica.revive(),
available_fn=lambda data: data.user.stats.hp == 0,
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,
),
)
CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.MPHEAL,
translation_key=HabiticaButtonEntity.MPHEAL,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.ETHEREAL_SURGE)
),
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 or 0) >= 12
and (data.user.stats.mp or 0) >= 30
lambda data: data.user["stats"]["lvl"] >= 12
and data.user["stats"]["mp"] >= 30
),
class_needed=HabiticaClass.MAGE,
class_needed=MAGE,
entity_picture="shop_mpheal.png",
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.EARTH,
translation_key=HabiticaButtonEntity.EARTH,
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.EARTHQUAKE),
key=HabitipyButtonEntity.EARTH,
translation_key=HabitipyButtonEntity.EARTH,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["earth"].post(),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 13
and (data.user.stats.mp or 0) >= 35
lambda data: data.user["stats"]["lvl"] >= 13
and data.user["stats"]["mp"] >= 35
),
class_needed=HabiticaClass.MAGE,
class_needed=MAGE,
entity_picture="shop_earth.png",
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.FROST,
translation_key=HabiticaButtonEntity.FROST,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.CHILLING_FROST)
),
key=HabitipyButtonEntity.FROST,
translation_key=HabitipyButtonEntity.FROST,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["frost"].post(),
# chilling frost can only be cast once per day (streaks buff is false)
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 14
and (data.user.stats.mp or 0) >= 40
and not data.user.stats.buffs.streaks
lambda data: data.user["stats"]["lvl"] >= 14
and data.user["stats"]["mp"] >= 40
and not data.user["stats"]["buffs"]["streaks"]
),
class_needed=HabiticaClass.MAGE,
class_needed=MAGE,
entity_picture="shop_frost.png",
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.DEFENSIVE_STANCE,
translation_key=HabiticaButtonEntity.DEFENSIVE_STANCE,
key=HabitipyButtonEntity.DEFENSIVE_STANCE,
translation_key=HabitipyButtonEntity.DEFENSIVE_STANCE,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.DEFENSIVE_STANCE)
lambda coordinator: coordinator.api.user.class_.cast[
"defensiveStance"
].post()
),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 12
and (data.user.stats.mp or 0) >= 25
lambda data: data.user["stats"]["lvl"] >= 12
and data.user["stats"]["mp"] >= 25
),
class_needed=HabiticaClass.WARRIOR,
class_needed=WARRIOR,
entity_picture="shop_defensiveStance.png",
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.VALOROUS_PRESENCE,
translation_key=HabiticaButtonEntity.VALOROUS_PRESENCE,
key=HabitipyButtonEntity.VALOROUS_PRESENCE,
translation_key=HabitipyButtonEntity.VALOROUS_PRESENCE,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.VALOROUS_PRESENCE)
lambda coordinator: coordinator.api.user.class_.cast[
"valorousPresence"
].post()
),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 13
and (data.user.stats.mp or 0) >= 20
lambda data: data.user["stats"]["lvl"] >= 13
and data.user["stats"]["mp"] >= 20
),
class_needed=HabiticaClass.WARRIOR,
class_needed=WARRIOR,
entity_picture="shop_valorousPresence.png",
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.INTIMIDATE,
translation_key=HabiticaButtonEntity.INTIMIDATE,
key=HabitipyButtonEntity.INTIMIDATE,
translation_key=HabitipyButtonEntity.INTIMIDATE,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.INTIMIDATING_GAZE)
lambda coordinator: coordinator.api.user.class_.cast["intimidate"].post()
),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 14
and (data.user.stats.mp or 0) >= 15
lambda data: data.user["stats"]["lvl"] >= 14
and data.user["stats"]["mp"] >= 15
),
class_needed=HabiticaClass.WARRIOR,
class_needed=WARRIOR,
entity_picture="shop_intimidate.png",
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.TOOLS_OF_TRADE,
translation_key=HabiticaButtonEntity.TOOLS_OF_TRADE,
key=HabitipyButtonEntity.TOOLS_OF_TRADE,
translation_key=HabitipyButtonEntity.TOOLS_OF_TRADE,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(
Skill.TOOLS_OF_THE_TRADE
)
lambda coordinator: coordinator.api.user.class_.cast["toolsOfTrade"].post()
),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 13
and (data.user.stats.mp or 0) >= 25
lambda data: data.user["stats"]["lvl"] >= 13
and data.user["stats"]["mp"] >= 25
),
class_needed=HabiticaClass.ROGUE,
class_needed=ROGUE,
entity_picture="shop_toolsOfTrade.png",
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.STEALTH,
translation_key=HabiticaButtonEntity.STEALTH,
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.STEALTH),
key=HabitipyButtonEntity.STEALTH,
translation_key=HabitipyButtonEntity.STEALTH,
press_fn=(
lambda coordinator: coordinator.api.user.class_.cast["stealth"].post()
),
# Stealth buffs stack and it can only be cast if the amount of
# buffs is smaller than the amount of unfinished dailies
# unfinished dailies is smaller than the amount of buffs
available_fn=(
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)
lambda data: data.user["stats"]["lvl"] >= 14
and data.user["stats"]["mp"] >= 45
and data.user["stats"]["buffs"]["stealth"]
< len(
[
r
for r in data.tasks
if r.Type is TaskType.DAILY
and r.isDue is True
and r.completed is False
if r.get("type") == "daily"
and r.get("isDue") is True
and r.get("completed") is False
]
)
),
class_needed=HabiticaClass.ROGUE,
class_needed=ROGUE,
entity_picture="shop_stealth.png",
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.HEAL,
translation_key=HabiticaButtonEntity.HEAL,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.HEALING_LIGHT)
),
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 or 0) >= 11
and (data.user.stats.mp or 0) >= 15
and (data.user.stats.hp or 0) < 50
lambda data: data.user["stats"]["lvl"] >= 11
and data.user["stats"]["mp"] >= 15
and data.user["stats"]["hp"] < 50
),
class_needed=HabiticaClass.HEALER,
class_needed=HEALER,
entity_picture="shop_heal.png",
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.BRIGHTNESS,
translation_key=HabiticaButtonEntity.BRIGHTNESS,
key=HabitipyButtonEntity.BRIGHTNESS,
translation_key=HabitipyButtonEntity.BRIGHTNESS,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(
Skill.SEARING_BRIGHTNESS
)
lambda coordinator: coordinator.api.user.class_.cast["brightness"].post()
),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 12
and (data.user.stats.mp or 0) >= 15
lambda data: data.user["stats"]["lvl"] >= 12
and data.user["stats"]["mp"] >= 15
),
class_needed=HabiticaClass.HEALER,
class_needed=HEALER,
entity_picture="shop_brightness.png",
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.PROTECT_AURA,
translation_key=HabiticaButtonEntity.PROTECT_AURA,
key=HabitipyButtonEntity.PROTECT_AURA,
translation_key=HabitipyButtonEntity.PROTECT_AURA,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.PROTECTIVE_AURA)
lambda coordinator: coordinator.api.user.class_.cast["protectAura"].post()
),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 13
and (data.user.stats.mp or 0) >= 30
lambda data: data.user["stats"]["lvl"] >= 13
and data.user["stats"]["mp"] >= 30
),
class_needed=HabiticaClass.HEALER,
class_needed=HEALER,
entity_picture="shop_protectAura.png",
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.HEAL_ALL,
translation_key=HabiticaButtonEntity.HEAL_ALL,
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.BLESSING),
key=HabitipyButtonEntity.HEAL_ALL,
translation_key=HabitipyButtonEntity.HEAL_ALL,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["healAll"].post(),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 14
and (data.user.stats.mp or 0) >= 25
lambda data: data.user["stats"]["lvl"] >= 14
and data.user["stats"]["mp"] >= 25
),
class_needed=HabiticaClass.HEALER,
class_needed=HEALER,
entity_picture="shop_healAll.png",
),
)
@@ -294,10 +285,10 @@ async def async_setup_entry(
for description in CLASS_SKILLS:
if (
(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
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"]
):
if description.key not in skills_added:
buttons.append(HabiticaButton(coordinator, description))
@@ -331,17 +322,17 @@ class HabiticaButton(HabiticaBase, ButtonEntity):
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator)
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:
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
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
+39 -46
View File
@@ -5,11 +5,8 @@ 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,
@@ -23,6 +20,7 @@ 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
@@ -85,7 +83,9 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity):
@property
def start_of_today(self) -> datetime:
"""Habitica daystart."""
return dt_util.start_of_local_day(self.coordinator.data.user.lastCron)
return dt_util.start_of_local_day(
datetime.fromisoformat(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 is TaskType.TODO
and not task.completed
and task.date is not None # only if has due date
task["type"] == HabiticaTaskType.TODO
and not task["completed"]
and task.get("date") # only if has due date
):
continue
start = dt_util.start_of_local_day(task.date)
start = dt_util.start_of_local_day(datetime.fromisoformat(task["date"]))
end = start + timedelta(days=1)
# return current and upcoming events or events within the requested range
@@ -132,23 +132,21 @@ 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=str(task.id),
summary=task["text"],
description=task["notes"],
uid=task["id"],
)
)
return sorted(
events,
key=lambda event: (
event.start,
self.coordinator.data.user.tasksOrder.todos.index(UUID(event.uid)),
self.coordinator.data.user["tasksOrder"]["todos"].index(event.uid),
),
)
@@ -191,7 +189,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
events = []
for task in self.coordinator.data.tasks:
# only dailies that that are not 'grey dailies'
if not (task.Type is TaskType.DAILY and task.everyX):
if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
continue
recurrences = build_rrule(task)
@@ -201,21 +199,19 @@ 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=str(task.id),
summary=task["text"],
description=task["notes"],
uid=task["id"],
rrule=get_recurrence_rule(recurrences),
)
)
@@ -223,7 +219,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
events,
key=lambda event: (
event.start,
self.coordinator.data.user.tasksOrder.dailys.index(UUID(event.uid)),
self.coordinator.data.user["tasksOrder"]["dailys"].index(event.uid),
),
)
@@ -258,14 +254,14 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
events = []
for task in self.coordinator.data.tasks:
if task.Type is not TaskType.TODO or task.completed:
if task["type"] != HabiticaTaskType.TODO or task["completed"]:
continue
for reminder in task.reminders:
for reminder in task.get("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 = reminder.time.replace(
start = datetime.fromisoformat(reminder["time"]).replace(
tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0
)
end = start + timedelta(hours=1)
@@ -277,16 +273,14 @@ 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"]}",
)
)
@@ -304,7 +298,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
translation_key=HabiticaCalendar.DAILY_REMINDERS,
)
def start(self, reminder_time: datetime, reminder_date: date) -> datetime:
def start(self, reminder_time: str, reminder_date: date) -> datetime:
"""Generate reminder times for dailies.
Reminders for dailies have a datetime but the date part is arbitrary,
@@ -313,10 +307,12 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
"""
return datetime.combine(
reminder_date,
reminder_time.replace(
datetime.fromisoformat(reminder_time)
.replace(
second=0,
microsecond=0,
).time(),
)
.time(),
tzinfo=dt_util.DEFAULT_TIME_ZONE,
)
@@ -331,7 +327,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
start_date = max(start_date, self.start_of_today)
for task in self.coordinator.data.tasks:
if not (task.Type is TaskType.DAILY and task.everyX):
if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
continue
recurrences = build_rrule(task)
@@ -343,30 +339,27 @@ 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.reminders:
start = self.start(reminder.time, recurrence)
for reminder in task.get("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"]}",
)
)
+65 -188
View File
@@ -2,25 +2,17 @@
from __future__ import annotations
from collections.abc import Mapping
from http import HTTPStatus
import logging
from typing import TYPE_CHECKING, Any
from typing import Any
from aiohttp import ClientError
from habiticalib import (
Habitica,
HabiticaException,
LoginData,
NotAuthorizedError,
UserData,
)
from aiohttp import ClientResponseError
from habitipy.aio import HabitipyAsync
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,
@@ -33,18 +25,14 @@ 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(
@@ -73,44 +61,14 @@ 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:
@@ -135,20 +93,39 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
"""
errors: dict[str, str] = {}
if user_input is not None:
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))
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"])
self._abort_if_unique_id_configured()
if TYPE_CHECKING:
assert user.profile.name
return self.async_create_entry(
title=user.profile.name,
title=login_response["username"],
data={
CONF_API_USER: str(login.id),
CONF_API_KEY: login.apiToken,
CONF_NAME: user.profile.name, # needed for api_call action
CONF_API_USER: login_response["id"],
CONF_API_KEY: login_response["apiToken"],
CONF_USERNAME: login_response["username"],
CONF_URL: DEFAULT_URL,
CONF_VERIFY_SSL: True,
},
@@ -173,20 +150,37 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
"""
errors: dict[str, str] = {}
if user_input is not None:
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.profile.name,
data={
**user_input,
CONF_URL: user_input.get(CONF_URL, DEFAULT_URL),
CONF_NAME: user.profile.name, # needed for api_call action
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"]
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
)
return self.async_show_form(
step_id="advanced",
@@ -199,120 +193,3 @@ 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
+6 -12
View File
@@ -1,6 +1,6 @@
"""Constants for the habitica integration."""
from homeassistant.const import APPLICATION_NAME, CONF_PATH, __version__
from homeassistant.const import CONF_PATH
CONF_API_USER = "api_user"
@@ -31,11 +31,6 @@ ATTR_TASK = "task"
ATTR_DIRECTION = "direction"
ATTR_TARGET = "target"
ATTR_ITEM = "item"
ATTR_TYPE = "type"
ATTR_PRIORITY = "priority"
ATTR_TAG = "tag"
ATTR_KEYWORD = "keyword"
SERVICE_CAST_SKILL = "cast_skill"
SERVICE_START_QUEST = "start_quest"
SERVICE_ACCEPT_QUEST = "accept_quest"
@@ -43,16 +38,15 @@ SERVICE_CANCEL_QUEST = "cancel_quest"
SERVICE_ABORT_QUEST = "abort_quest"
SERVICE_REJECT_QUEST = "reject_quest"
SERVICE_LEAVE_QUEST = "leave_quest"
SERVICE_GET_TASKS = "get_tasks"
SERVICE_SCORE_HABIT = "score_habit"
SERVICE_SCORE_REWARD = "score_reward"
SERVICE_TRANSFORMATION = "transformation"
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"
WARRIOR = "warrior"
ROGUE = "rogue"
HEALER = "healer"
MAGE = "wizard"
SECTION_REAUTH_LOGIN = "reauth_login"
SECTION_REAUTH_API_KEY = "reauth_api_key"
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
@@ -5,31 +5,16 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from io import BytesIO
from http import HTTPStatus
import logging
from typing import Any
from aiohttp import ClientError
from habiticalib import (
ContentData,
Habitica,
HabiticaException,
NotAuthorizedError,
TaskData,
TaskFilter,
TooManyRequestsError,
UserData,
UserStyles,
)
from aiohttp import ClientResponseError
from habitipy.aio import HabitipyAsync
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -40,10 +25,10 @@ _LOGGER = logging.getLogger(__name__)
@dataclass
class HabiticaData:
"""Habitica data."""
"""Coordinator data class."""
user: UserData
tasks: list[TaskData]
user: dict[str, Any]
tasks: list[dict]
class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
@@ -51,7 +36,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, habitica: Habitica) -> None:
def __init__(self, hass: HomeAssistant, habitipy: HabitipyAsync) -> None:
"""Initialize the Habitica data coordinator."""
super().__init__(
hass,
@@ -65,53 +50,25 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
immediate=False,
),
)
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},
)
self.api = habitipy
self.content: dict[str, Any] = {}
async def _async_update_data(self) -> HabiticaData:
try:
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)
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)
async def execute(
self, func: Callable[[HabiticaDataUpdateCoordinator], Any]
@@ -120,25 +77,15 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
try:
await func(self)
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
except (HabiticaException, ClientError) as e:
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
else:
await self.async_request_refresh()
async def generate_avatar(self, user_styles: UserStyles) -> bytes:
"""Generate Avatar."""
avatar = BytesIO()
await self.habitica.generate_avatar(
fp=avatar, user_styles=user_styles, fmt="PNG"
)
return avatar.getvalue()
@@ -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.habitica.get_user_anonymized()
habitica_data = await config_entry.runtime_data.api.user.anonymized.get()
return {
"config_entry_data": {
CONF_URL: config_entry.data[CONF_URL],
CONF_API_USER: config_entry.data[CONF_API_USER],
},
"habitica_data": habitica_data.to_dict()["data"],
"habitica_data": habitica_data,
}
+6 -6
View File
@@ -121,6 +121,12 @@
"rogue": "mdi:ninja"
}
},
"todos": {
"default": "mdi:checkbox-outline"
},
"dailys": {
"default": "mdi:calendar-month"
},
"habits": {
"default": "mdi:contrast-box"
},
@@ -190,12 +196,6 @@
},
"transformation": {
"service": "mdi:flask-round-bottom"
},
"get_tasks": {
"service": "mdi:calendar-export",
"sections": {
"filter": "mdi:calendar-filter"
}
}
}
}
@@ -1,76 +0,0 @@
"""Image platform for Habitica integration."""
from __future__ import annotations
from dataclasses import asdict
from enum import StrEnum
from habiticalib import UserStyles
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from . import HabiticaConfigEntry
from .coordinator import HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
class HabiticaImageEntity(StrEnum):
"""Image entities."""
AVATAR = "avatar"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HabiticaConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the habitica image platform."""
coordinator = config_entry.runtime_data
async_add_entities([HabiticaImage(hass, coordinator)])
class HabiticaImage(HabiticaBase, ImageEntity):
"""A Habitica image entity."""
entity_description = ImageEntityDescription(
key=HabiticaImageEntity.AVATAR,
translation_key=HabiticaImageEntity.AVATAR,
)
_attr_content_type = "image/png"
_current_appearance: UserStyles | None = None
_cache: bytes | None = None
def __init__(
self,
hass: HomeAssistant,
coordinator: HabiticaDataUpdateCoordinator,
) -> None:
"""Initialize the image entity."""
super().__init__(coordinator, self.entity_description)
ImageEntity.__init__(self, hass)
self._attr_image_last_updated = dt_util.utcnow()
def _handle_coordinator_update(self) -> None:
"""Check if equipped gear and other things have changed since last avatar image generation."""
new_appearance = UserStyles.from_dict(asdict(self.coordinator.data.user))
if self._current_appearance != new_appearance:
self._current_appearance = new_appearance
self._attr_image_last_updated = dt_util.utcnow()
self._cache = None
return super()._handle_coordinator_update()
async def async_image(self) -> bytes | None:
"""Return cached bytes, otherwise generate new avatar."""
if not self._cache and self._current_appearance:
self._cache = await self.coordinator.generate_avatar(
self._current_appearance
)
return self._cache
@@ -5,6 +5,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling",
"loggers": ["habiticalib"],
"requirements": ["habiticalib==0.3.2"]
"loggers": ["habitipy", "plumbum"],
"requirements": ["habitipy==0.3.3"]
}
@@ -34,7 +34,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: done
reauthentication-flow: todo
test-coverage: done
# Gold
+163 -109
View File
@@ -3,57 +3,55 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import asdict, dataclass
from dataclasses import dataclass
from enum import StrEnum
import logging
from typing import Any
from habiticalib import (
ContentData,
HabiticaClass,
TaskData,
TaskType,
UserData,
deserialize_task,
)
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.typing import StateType
from .const import ASSETS_URL
from .const import ASSETS_URL, DOMAIN
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
from .util import get_attribute_points, get_attributes_total
from .util import entity_used_in, get_attribute_points, get_attributes_total
_LOGGER = logging.getLogger(__name__)
@dataclass(kw_only=True, frozen=True)
class HabiticaSensorEntityDescription(SensorEntityDescription):
"""Habitica Sensor Description."""
class HabitipySensorEntityDescription(SensorEntityDescription):
"""Habitipy Sensor Description."""
value_fn: Callable[[UserData, ContentData], StateType]
attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = (
None
)
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
entity_picture: str | None = None
@dataclass(kw_only=True, frozen=True)
class HabiticaTaskSensorEntityDescription(SensorEntityDescription):
"""Habitica Task Sensor Description."""
class HabitipyTaskSensorEntityDescription(SensorEntityDescription):
"""Habitipy Task Sensor Description."""
value_fn: Callable[[list[TaskData]], list[TaskData]]
value_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]]
class HabiticaSensorEntity(StrEnum):
"""Habitica Entities."""
class HabitipySensorEntity(StrEnum):
"""Habitipy Entities."""
DISPLAY_NAME = "display_name"
HEALTH = "health"
@@ -66,6 +64,8 @@ class HabiticaSensorEntity(StrEnum):
GOLD = "gold"
CLASS = "class"
HABITS = "habits"
DAILIES = "dailys"
TODOS = "todos"
REWARDS = "rewards"
GEMS = "gems"
TRINKETS = "trinkets"
@@ -75,105 +75,110 @@ class HabiticaSensorEntity(StrEnum):
PERCEPTION = "perception"
SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.DISPLAY_NAME,
translation_key=HabiticaSensorEntity.DISPLAY_NAME,
value_fn=lambda user, _: user.profile.name,
SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
HabitipySensorEntityDescription(
key=HabitipySensorEntity.DISPLAY_NAME,
translation_key=HabitipySensorEntity.DISPLAY_NAME,
value_fn=lambda user, _: user.get("profile", {}).get("name"),
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.HEALTH,
translation_key=HabiticaSensorEntity.HEALTH,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.HEALTH,
translation_key=HabitipySensorEntity.HEALTH,
suggested_display_precision=0,
value_fn=lambda user, _: user.stats.hp,
value_fn=lambda user, _: user.get("stats", {}).get("hp"),
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.HEALTH_MAX,
translation_key=HabiticaSensorEntity.HEALTH_MAX,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.HEALTH_MAX,
translation_key=HabitipySensorEntity.HEALTH_MAX,
entity_registry_enabled_default=False,
value_fn=lambda user, _: 50,
value_fn=lambda user, _: user.get("stats", {}).get("maxHealth"),
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.MANA,
translation_key=HabiticaSensorEntity.MANA,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.MANA,
translation_key=HabitipySensorEntity.MANA,
suggested_display_precision=0,
value_fn=lambda user, _: user.stats.mp,
value_fn=lambda user, _: user.get("stats", {}).get("mp"),
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.MANA_MAX,
translation_key=HabiticaSensorEntity.MANA_MAX,
value_fn=lambda user, _: user.stats.maxMP,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.MANA_MAX,
translation_key=HabitipySensorEntity.MANA_MAX,
value_fn=lambda user, _: user.get("stats", {}).get("maxMP"),
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.EXPERIENCE,
translation_key=HabiticaSensorEntity.EXPERIENCE,
value_fn=lambda user, _: user.stats.exp,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.EXPERIENCE,
translation_key=HabitipySensorEntity.EXPERIENCE,
value_fn=lambda user, _: user.get("stats", {}).get("exp"),
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.EXPERIENCE_MAX,
translation_key=HabiticaSensorEntity.EXPERIENCE_MAX,
value_fn=lambda user, _: user.stats.toNextLevel,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.EXPERIENCE_MAX,
translation_key=HabitipySensorEntity.EXPERIENCE_MAX,
value_fn=lambda user, _: user.get("stats", {}).get("toNextLevel"),
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.LEVEL,
translation_key=HabiticaSensorEntity.LEVEL,
value_fn=lambda user, _: user.stats.lvl,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.LEVEL,
translation_key=HabitipySensorEntity.LEVEL,
value_fn=lambda user, _: user.get("stats", {}).get("lvl"),
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.GOLD,
translation_key=HabiticaSensorEntity.GOLD,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.GOLD,
translation_key=HabitipySensorEntity.GOLD,
suggested_display_precision=2,
value_fn=lambda user, _: user.stats.gp,
value_fn=lambda user, _: user.get("stats", {}).get("gp"),
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.CLASS,
translation_key=HabiticaSensorEntity.CLASS,
value_fn=lambda user, _: user.stats.Class.value if user.stats.Class else None,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.CLASS,
translation_key=HabitipySensorEntity.CLASS,
value_fn=lambda user, _: user.get("stats", {}).get("class"),
device_class=SensorDeviceClass.ENUM,
options=[item.value for item in HabiticaClass],
options=["warrior", "healer", "wizard", "rogue"],
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.GEMS,
translation_key=HabiticaSensorEntity.GEMS,
value_fn=lambda user, _: round(user.balance * 4) if user.balance else None,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.GEMS,
translation_key=HabitipySensorEntity.GEMS,
value_fn=lambda user, _: user.get("balance", 0) * 4,
suggested_display_precision=0,
entity_picture="shop_gem.png",
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.TRINKETS,
translation_key=HabiticaSensorEntity.TRINKETS,
value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets or 0,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.TRINKETS,
translation_key=HabitipySensorEntity.TRINKETS,
value_fn=(
lambda user, _: user.get("purchased", {})
.get("plan", {})
.get("consecutive", {})
.get("trinkets", 0)
),
suggested_display_precision=0,
native_unit_of_measurement="",
entity_picture="notif_subscriber_reward.png",
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.STRENGTH,
translation_key=HabiticaSensorEntity.STRENGTH,
value_fn=lambda user, content: get_attributes_total(user, content, "Str"),
attributes_fn=lambda user, content: get_attribute_points(user, content, "Str"),
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"),
suggested_display_precision=0,
native_unit_of_measurement="STR",
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.INTELLIGENCE,
translation_key=HabiticaSensorEntity.INTELLIGENCE,
value_fn=lambda user, content: get_attributes_total(user, content, "Int"),
attributes_fn=lambda user, content: get_attribute_points(user, content, "Int"),
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"),
suggested_display_precision=0,
native_unit_of_measurement="INT",
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.PERCEPTION,
translation_key=HabiticaSensorEntity.PERCEPTION,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.PERCEPTION,
translation_key=HabitipySensorEntity.PERCEPTION,
value_fn=lambda user, content: get_attributes_total(user, content, "per"),
attributes_fn=lambda user, content: get_attribute_points(user, content, "per"),
suggested_display_precision=0,
native_unit_of_measurement="PER",
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.CONSTITUTION,
translation_key=HabiticaSensorEntity.CONSTITUTION,
HabitipySensorEntityDescription(
key=HabitipySensorEntity.CONSTITUTION,
translation_key=HabitipySensorEntity.CONSTITUTION,
value_fn=lambda user, content: get_attributes_total(user, content, "con"),
attributes_fn=lambda user, content: get_attribute_points(user, content, "con"),
suggested_display_precision=0,
@@ -198,7 +203,7 @@ TASKS_MAP = {
"yester_daily": "yesterDaily",
"completed": "completed",
"collapse_checklist": "collapseChecklist",
"type": "Type",
"type": "type",
"notes": "notes",
"tags": "tags",
"value": "value",
@@ -212,16 +217,30 @@ TASKS_MAP = {
}
TASK_SENSOR_DESCRIPTION: tuple[HabiticaTaskSensorEntityDescription, ...] = (
HabiticaTaskSensorEntityDescription(
key=HabiticaSensorEntity.HABITS,
translation_key=HabiticaSensorEntity.HABITS,
value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.HABIT],
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"],
),
HabiticaTaskSensorEntityDescription(
key=HabiticaSensorEntity.REWARDS,
translation_key=HabiticaSensorEntity.REWARDS,
value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.REWARD],
HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.DAILIES,
translation_key=HabitipySensorEntity.DAILIES,
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "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")
],
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"],
),
)
@@ -236,19 +255,19 @@ async def async_setup_entry(
coordinator = config_entry.runtime_data
entities: list[SensorEntity] = [
HabiticaSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS
HabitipySensor(coordinator, description) for description in SENSOR_DESCRIPTIONS
]
entities.extend(
HabiticaTaskSensor(coordinator, description)
HabitipyTaskSensor(coordinator, description)
for description in TASK_SENSOR_DESCRIPTION
)
async_add_entities(entities, True)
class HabiticaSensor(HabiticaBase, SensorEntity):
class HabitipySensor(HabiticaBase, SensorEntity):
"""A generic Habitica sensor."""
entity_description: HabiticaSensorEntityDescription
entity_description: HabitipySensorEntityDescription
@property
def native_value(self) -> StateType:
@@ -273,10 +292,10 @@ class HabiticaSensor(HabiticaBase, SensorEntity):
return None
class HabiticaTaskSensor(HabiticaBase, SensorEntity):
class HabitipyTaskSensor(HabiticaBase, SensorEntity):
"""A Habitica task sensor."""
entity_description: HabiticaTaskSensorEntityDescription
entity_description: HabitipyTaskSensorEntityDescription
@property
def native_value(self) -> StateType:
@@ -290,12 +309,47 @@ class HabiticaTaskSensor(HabiticaBase, SensorEntity):
attrs = {}
# Map tasks to TASKS_MAP
for task_data in self.entity_description.value_fn(self.coordinator.data.tasks):
received_task = deserialize_task(asdict(task_data))
for received_task in self.entity_description.value_fn(
self.coordinator.data.tasks
):
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[str(task_id)] = task
attrs[task_id] = task
return attrs
async def async_added_to_hass(self) -> None:
"""Raise issue when entity is registered and was not disabled."""
if TYPE_CHECKING:
assert self.unique_id
if entity_id := er.async_get(self.hass).async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, self.unique_id
):
if (
self.enabled
and self.entity_description.key
in (HabitipySensorEntity.TODOS, HabitipySensorEntity.DAILIES)
and entity_used_in(self.hass, entity_id)
):
async_create_issue(
self.hass,
DOMAIN,
f"deprecated_task_entity_{self.entity_description.key}",
breaks_in_ha_version="2025.2.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_task_entity",
translation_placeholders={
"task_name": str(self.name),
"entity": entity_id,
},
)
else:
async_delete_issue(
self.hass,
DOMAIN,
f"deprecated_task_entity_{self.entity_description.key}",
)
await super().async_added_to_hass()
+128 -225
View File
@@ -2,22 +2,11 @@
from __future__ import annotations
from dataclasses import asdict
from http import HTTPStatus
import logging
from typing import TYPE_CHECKING, Any
from typing import Any
from aiohttp import ClientError
from habiticalib import (
Direction,
HabiticaException,
NotAuthorizedError,
NotFoundError,
Skill,
TaskData,
TaskPriority,
TaskType,
TooManyRequestsError,
)
from aiohttp import ClientResponseError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
@@ -39,14 +28,10 @@ from .const import (
ATTR_DATA,
ATTR_DIRECTION,
ATTR_ITEM,
ATTR_KEYWORD,
ATTR_PATH,
ATTR_PRIORITY,
ATTR_SKILL,
ATTR_TAG,
ATTR_TARGET,
ATTR_TASK,
ATTR_TYPE,
DOMAIN,
EVENT_API_CALL_SUCCESS,
SERVICE_ABORT_QUEST,
@@ -54,7 +39,6 @@ from .const import (
SERVICE_API_CALL,
SERVICE_CANCEL_QUEST,
SERVICE_CAST_SKILL,
SERVICE_GET_TASKS,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
SERVICE_SCORE_HABIT,
@@ -104,40 +88,6 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
}
)
SERVICE_GET_TASKS_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
vol.Optional(ATTR_TYPE): vol.All(
cv.ensure_list, [vol.All(vol.Upper, vol.In({x.name for x in TaskType}))]
),
vol.Optional(ATTR_PRIORITY): vol.All(
cv.ensure_list, [vol.All(vol.Upper, vol.In({x.name for x in TaskPriority}))]
),
vol.Optional(ATTR_TASK): vol.All(cv.ensure_list, [str]),
vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]),
vol.Optional(ATTR_KEYWORD): cv.string,
}
)
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."""
@@ -173,12 +123,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
name = call.data[ATTR_NAME]
path = call.data[ATTR_PATH]
entries: list[HabiticaConfigEntry] = hass.config_entries.async_entries(DOMAIN)
entries = hass.config_entries.async_entries(DOMAIN)
api = None
for entry in entries:
if entry.data[CONF_NAME] == name:
api = await entry.runtime_data.habitica.habitipy()
api = entry.runtime_data.api
break
if api is None:
_LOGGER.error("API_CALL: User '%s' not configured", name)
@@ -201,15 +151,18 @@ 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 = SKILL_MAP[call.data[ATTR_SKILL]]
cost = COST_MAP[call.data[ATTR_SKILL]]
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"},
}
try:
task_id = next(
task.id
task["id"]
for task in coordinator.data.tasks
if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
or call.data[ATTR_TASK] == task["text"]
)
except StopIteration as e:
raise ServiceValidationError(
@@ -219,76 +172,75 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
) from e
try:
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:
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
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
else:
await coordinator.async_request_refresh()
return asdict(response.data)
return response
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
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,
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 = FUNC_MAP[call.service]
try:
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:
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
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="service_call_exception"
) from e
else:
return asdict(response.data)
for service in (
SERVICE_ABORT_QUEST,
@@ -310,15 +262,12 @@ 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.value)
(task["id"], task.get("value"))
for task in coordinator.data.tasks
if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
or call.data[ATTR_TASK] == task["text"]
)
except StopIteration as e:
raise ServiceValidationError(
@@ -327,76 +276,81 @@ 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 = 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:
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:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_enough_gold",
translation_placeholders={
"gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP",
"cost": f"{task_value:.2f} GP",
"gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP",
"cost": f"{task_value} 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 asdict(response.data)
return response
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
item = ITEMID_MAP[call.data[ATTR_ITEM]]
ITEMID_MAP = {
"snowball": {"itemId": "snowball"},
"spooky_sparkles": {"itemId": "spookySparkles"},
"seafoam": {"itemId": "seafoam"},
"shiny_seed": {"itemId": "shinySeed"},
}
# check if target is self
if call.data[ATTR_TARGET] in (
str(coordinator.data.user.id),
coordinator.data.user.profile.name,
coordinator.data.user.auth.local.username,
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.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:
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
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
try:
target_id = next(
member.id
for member in party.data
if member.id
and call.data[ATTR_TARGET].lower()
member["id"]
for member in party
if call.data[ATTR_TARGET].lower()
in (
str(member.id),
str(member.auth.local.username).lower(),
str(member.profile.name).lower(),
member["id"],
member["auth"]["local"]["username"].lower(),
member["profile"]["name"].lower(),
)
)
except StopIteration as e:
@@ -406,71 +360,27 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"},
) from e
try:
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:
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
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
else:
return asdict(response.data)
async def get_tasks(call: ServiceCall) -> ServiceResponse:
"""Get tasks action."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
response: list[TaskData] = coordinator.data.tasks
if types := {TaskType[x] for x in call.data.get(ATTR_TYPE, [])}:
response = [task for task in response if task.Type in types]
if priority := {TaskPriority[x] for x in call.data.get(ATTR_PRIORITY, [])}:
response = [task for task in response if task.priority in priority]
if tasks := call.data.get(ATTR_TASK):
response = [
task
for task in response
if str(task.id) in tasks or task.alias in tasks or task.text in tasks
]
if tags := call.data.get(ATTR_TAG):
tag_ids = {
tag.id
for tag in coordinator.data.user.tags
if (tag.name and tag.name.lower())
in (tag.lower() for tag in tags) # Case-insensitive matching
and tag.id
}
response = [
task
for task in response
if any(tag_id in task.tags for tag_id in tag_ids if task.tags)
]
if keyword := call.data.get(ATTR_KEYWORD):
keyword = keyword.lower()
response = [
task
for task in response
if (task.text and keyword in task.text.lower())
or (task.notes and keyword in task.notes.lower())
or any(keyword in item.text.lower() for item in task.checklist)
]
result: dict[str, Any] = {"tasks": response}
return result
return response
hass.services.async_register(
DOMAIN,
@@ -509,10 +419,3 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
schema=SERVICE_TRANSFORMATION_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_TASKS,
get_tasks,
schema=SERVICE_GET_TASKS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@@ -94,49 +94,3 @@ transformation:
required: true
selector:
text:
get_tasks:
fields:
config_entry: *config_entry
filter:
collapsed: true
fields:
type:
required: false
selector:
select:
options:
- "habit"
- "daily"
- "todo"
- "reward"
mode: dropdown
translation_key: "type"
multiple: true
sort: true
priority:
required: false
selector:
select:
options:
- "trivial"
- "easy"
- "medium"
- "hard"
mode: dropdown
translation_key: "priority"
multiple: true
sort: false
task:
required: false
selector:
text:
multiple: true
tag:
required: false
selector:
text:
multiple: true
keyword:
required: false
selector:
text:
+26 -107
View File
@@ -3,7 +3,6 @@
"todos": "To-Do's",
"dailies": "Dailies",
"config_entry_name": "Select character",
"task_name": "Task name",
"unit_tasks": "tasks",
"unit_health_points": "HP",
"unit_mana_points": "MP",
@@ -11,15 +10,12 @@
},
"config": {
"abort": {
"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%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"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%]",
"invalid_credentials": "Input is incomplete. You must provide either your login details or an API token"
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
@@ -53,38 +49,9 @@
"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",
"verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a Habitica instance using a self-signed certificate"
"api_key": "API Token of the Habitica account"
},
"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%]"
}
}
}
}
}
},
@@ -167,11 +134,6 @@
"name": "Daily reminders"
}
},
"image": {
"avatar": {
"name": "Avatar"
}
},
"sensor": {
"display_name": {
"name": "Display name"
@@ -223,6 +185,14 @@
"rogue": "Rogue"
}
},
"todos": {
"name": "[%key:component::habitica::common::todos%]",
"unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
},
"dailys": {
"name": "[%key:component::habitica::common::dailies%]",
"unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
},
"habits": {
"name": "Habits",
"unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
@@ -395,12 +365,13 @@
},
"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": {
"deprecated_task_entity": {
"title": "The Habitica {task_name} sensor is deprecated",
"description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`."
},
"deprecated_api_call": {
"title": "The Habitica action habitica.api_call is deprecated",
"description": "The Habitica action `habitica.api_call` is deprecated and will be removed in Home Assistant 2025.5.0.\n\nPlease update your automations and scripts to use other Habitica actions and entities."
@@ -427,7 +398,7 @@
},
"cast_skill": {
"name": "Cast a skill",
"description": "Uses a skill or spell from your Habitica character on a specific task to affect its progress or status.",
"description": "Use a skill or spell from your Habitica character on a specific task to affect its progress or status.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -438,14 +409,14 @@
"description": "Select the skill or spell you want to cast on the task. Only skills corresponding to your character's class can be used."
},
"task": {
"name": "[%key:component::habitica::common::task_name%]",
"name": "Task name",
"description": "The name (or task ID) of the task you want to target with the skill or spell."
}
}
},
"accept_quest": {
"name": "Accept a quest invitation",
"description": "Accepts a pending invitation to a quest.",
"description": "Accept a pending invitation to a quest.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -455,7 +426,7 @@
},
"reject_quest": {
"name": "Reject a quest invitation",
"description": "Rejects a pending invitation to a quest.",
"description": "Reject a pending invitation to a quest.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -465,7 +436,7 @@
},
"leave_quest": {
"name": "Leave a quest",
"description": "Leaves the current quest you are participating in.",
"description": "Leave the current quest you are participating in.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -475,7 +446,7 @@
},
"abort_quest": {
"name": "Abort an active quest",
"description": "Terminates your party's ongoing quest. All progress will be lost and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
"description": "Terminate your party's ongoing quest. All progress will be lost and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -485,7 +456,7 @@
},
"cancel_quest": {
"name": "Cancel a pending quest",
"description": "Cancels a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
"description": "Cancel a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -495,7 +466,7 @@
},
"start_quest": {
"name": "Force-start a pending quest",
"description": "Begins the quest immediately, bypassing any pending invitations that haven't been accepted or rejected. Only quest leader or group leader can perform this action.",
"description": "Begin the quest immediately, bypassing any pending invitations that haven't been accepted or rejected. Only quest leader or group leader can perform this action.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -505,7 +476,7 @@
},
"score_habit": {
"name": "Track a habit",
"description": "Increases the positive or negative streak of a habit to track its progress.",
"description": "Increase the positive or negative streak of a habit to track its progress.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -523,7 +494,7 @@
},
"score_reward": {
"name": "Buy a reward",
"description": "Buys one of your custom rewards with gold earned by fulfilling tasks.",
"description": "Reward yourself and buy one of your custom rewards with gold earned by fulfilling tasks.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -537,7 +508,7 @@
},
"transformation": {
"name": "Use a transformation item",
"description": "Uses a transformation item from your Habitica character's inventory on a member of your party or yourself.",
"description": "Use a transformation item from your Habitica character's inventory on a member of your party or yourself.",
"fields": {
"config_entry": {
"name": "Select character",
@@ -552,42 +523,6 @@
"description": "The name of the character you want to use the transformation item on. You can also specify the players username or user ID."
}
}
},
"get_tasks": {
"name": "Get tasks",
"description": "Retrieves tasks from your Habitica character.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "Choose the Habitica character to retrieve tasks from."
},
"type": {
"name": "Task type",
"description": "Filter tasks by type."
},
"priority": {
"name": "Difficulty",
"description": "Filter tasks by difficulty."
},
"task": {
"name": "[%key:component::habitica::common::task_name%]",
"description": "Select tasks by matching their name (or task ID)."
},
"tag": {
"name": "Tag",
"description": "Filter tasks that have one or more of the selected tags."
},
"keyword": {
"name": "Keyword",
"description": "Filter tasks by keyword, searching across titles, notes, and checklists."
}
},
"sections": {
"filter": {
"name": "Filter options",
"description": "Use the optional filters to narrow the returned tasks."
}
}
}
},
"selector": {
@@ -606,22 +541,6 @@
"seafoam": "Seafoam",
"shiny_seed": "Shiny seed"
}
},
"type": {
"options": {
"daily": "Daily",
"habit": "Habit",
"todo": "To-do",
"reward": "Reward"
}
},
"priority": {
"options": {
"trivial": "Trivial",
"easy": "Easy",
"medium": "Medium",
"hard": "Hard"
}
}
}
}
+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 | None]
is_on_fn: Callable[[HabiticaData], bool]
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.habitica.toggle_sleep(),
turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
is_on_fn=lambda data: data.user.preferences.sleep,
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"],
),
)
+57 -57
View File
@@ -2,12 +2,11 @@
from __future__ import annotations
import datetime
from enum import StrEnum
from typing import TYPE_CHECKING
from uuid import UUID
from aiohttp import ClientError
from habiticalib import Direction, HabiticaException, Task, TaskType
from aiohttp import ClientResponseError
from homeassistant.components import persistent_notification
from homeassistant.components.todo import (
@@ -25,7 +24,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
from .types import HabiticaConfigEntry, HabiticaTaskType
from .util import next_due_date
PARALLEL_UPDATES = 1
@@ -71,8 +70,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
"""Delete Habitica tasks."""
if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS:
try:
await self.coordinator.habitica.delete_completed_todos()
except (HabiticaException, ClientError) as e:
await self.coordinator.api.tasks.clearCompletedTodos.post()
except ClientResponseError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="delete_completed_todos_failed",
@@ -80,8 +79,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
else:
for task_id in uids:
try:
await self.coordinator.habitica.delete_task(UUID(task_id))
except (HabiticaException, ClientError) as e:
await self.coordinator.api.tasks[task_id].delete()
except ClientResponseError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"delete_{self.entity_description.key}_failed",
@@ -107,8 +106,9 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
pos = 0
try:
await self.coordinator.habitica.reorder_task(UUID(uid), pos)
except (HabiticaException, ClientError) as e:
await self.coordinator.api.tasks[uid].move.to[str(pos)].post()
except ClientResponseError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"move_{self.entity_description.key}_item_failed",
@@ -118,14 +118,12 @@ 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 == UUID(previous_uid))
)
tasks.index(next(task for task in tasks if task["id"] == previous_uid))
+ 1
if previous_uid
else 0
)
old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid)))
old_pos = tasks.index(next(task for task in tasks if task["id"] == uid))
tasks.insert(new_pos, tasks.pop(old_pos))
await self.coordinator.async_request_refresh()
@@ -140,17 +138,14 @@ 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.
task["date"] = item.due
date = item.due.isoformat()
else:
date = None
if (
item.summary != current_item.summary
@@ -158,9 +153,13 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
or item.due != current_item.due
):
try:
await self.coordinator.habitica.update_task(UUID(item.uid), task)
await self.coordinator.api.tasks[item.uid].put(
text=item.summary,
notes=item.description or "",
date=date,
)
refresh_required = True
except (HabiticaException, ClientError) as e:
except ClientResponseError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"update_{self.entity_description.key}_item_failed",
@@ -173,33 +172,32 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
current_item.status is TodoItemStatus.NEEDS_ACTION
and item.status == TodoItemStatus.COMPLETED
):
score_result = await self.coordinator.habitica.update_score(
UUID(item.uid), Direction.UP
score_result = (
await self.coordinator.api.tasks[item.uid].score["up"].post()
)
refresh_required = True
elif (
current_item.status is TodoItemStatus.COMPLETED
and item.status == TodoItemStatus.NEEDS_ACTION
):
score_result = await self.coordinator.habitica.update_score(
UUID(item.uid), Direction.DOWN
score_result = (
await self.coordinator.api.tasks[item.uid].score["down"].post()
)
refresh_required = True
else:
score_result = None
except (HabiticaException, ClientError) as e:
except ClientResponseError 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 score_result.data.tmp.drop.key:
drop = score_result.data.tmp.drop
if score_result and (drop := score_result.get("_tmp", {}).get("drop", False)):
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"
@@ -231,36 +229,38 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
return [
*(
TodoItem(
uid=str(task.id),
summary=task.text,
description=task.notes,
due=dt_util.as_local(task.date).date() if task.date else None,
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
),
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 is TaskType.TODO
if task["type"] == HabiticaTaskType.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.habitica.create_task(
Task(
text=item.summary,
type=TaskType.TODO,
notes=item.description,
date=item.due,
)
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,
)
except (HabiticaException, ClientError) as e:
except ClientResponseError 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.
"""
if TYPE_CHECKING:
assert self.coordinator.data.user.lastCron
last_cron = self.coordinator.data.user["lastCron"]
return [
*(
TodoItem(
uid=str(task.id),
summary=task.text,
description=task.notes,
due=next_due_date(task, self.coordinator.data.user.lastCron),
uid=task["id"],
summary=task["text"],
description=task["notes"],
due=next_due_date(task, last_cron),
status=(
TodoItemStatus.COMPLETED
if task.completed
if task["completed"]
else TodoItemStatus.NEEDS_ACTION
),
)
for task in self.coordinator.data.tasks
if task.Type is TaskType.DAILY
if task["type"] == HabiticaTaskType.DAILY
)
]
+75 -45
View File
@@ -2,10 +2,9 @@
from __future__ import annotations
from dataclasses import fields
import datetime
from math import floor
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from dateutil.rrule import (
DAILY,
@@ -21,7 +20,6 @@ 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
@@ -29,32 +27,50 @@ from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | None:
def next_due_date(task: dict[str, Any], last_cron: str) -> datetime.date | None:
"""Calculate due date for dailies and yesterdailies."""
if task.everyX == 0 or not task.nextDue: # grey dailies never become due
if task["everyX"] == 0 or not task.get("nextDue"): # grey dailies never become due
return None
today = to_date(last_cron)
startdate = to_date(task["startDate"])
if TYPE_CHECKING:
assert task.startDate
assert today
assert startdate
if task.isDue is True and not task.completed:
return dt_util.as_local(today).date()
if task["isDue"] and not task["completed"]:
return to_date(last_cron)
if task.startDate > today:
if task.frequency is Frequency.DAILY or (
task.frequency in (Frequency.MONTHLY, Frequency.YEARLY) and task.daysOfMonth
if startdate > today:
if task["frequency"] == "daily" or (
task["frequency"] in ("monthly", "yearly") and task["daysOfMonth"]
):
return dt_util.as_local(task.startDate).date()
return startdate
if (
task.frequency in (Frequency.WEEKLY, Frequency.MONTHLY)
and (nextdue := task.nextDue[0])
and task.startDate > nextdue
task["frequency"] in ("weekly", "monthly")
and (nextdue := to_date(task["nextDue"][0]))
and startdate > nextdue
):
return dt_util.as_local(task.nextDue[1]).date()
return to_date(task["nextDue"][1])
return dt_util.as_local(task.nextDue[0]).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
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
@@ -68,27 +84,30 @@ 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: TaskData) -> rrule:
def build_rrule(task: dict[str, Any]) -> rrule:
"""Build rrule string."""
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)]
rrule_frequency = FREQUENCY_MAP.get(task["frequency"], DAILY)
weekdays = [
WEEKDAY_MAP[day] for day, is_active in task["repeat"].items() if is_active
]
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(task.startDate),
interval=task["everyX"],
dtstart=dt_util.start_of_local_day(
datetime.datetime.fromisoformat(task["startDate"])
),
byweekday=weekdays if rrule_frequency in [WEEKLY, MONTHLY] else None,
bymonthday=bymonthday,
bysetpos=bysetpos,
@@ -124,37 +143,48 @@ def get_recurrence_rule(recurrence: rrule) -> str:
def get_attribute_points(
user: UserData, content: ContentData, attribute: str
user: dict[str, Any], content: dict[str, Any], attribute: str
) -> dict[str, float]:
"""Get modifiers contributing to STR/INT/CON/PER attributes."""
"""Get modifiers contributing to strength attribute."""
gear_set = {
"weapon",
"armor",
"head",
"shield",
"back",
"headAccessory",
"eyewear",
"body",
}
equipment = sum(
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])
stats[attribute]
for gear in gear_set
if (equipped := user["items"]["gear"]["equipped"].get(gear))
and (stats := content["gear"]["flat"].get(equipped))
)
class_bonus = sum(
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
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"]
)
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": getattr(user.stats, attribute),
"buffs": getattr(user.stats.buffs, attribute),
"allocated": user["stats"][attribute],
"buffs": user["stats"]["buffs"][attribute],
}
def get_attributes_total(user: UserData, content: ContentData, attribute: str) -> int:
def get_attributes_total(
user: dict[str, Any], content: dict[str, Any], attribute: str
) -> int:
"""Get total attribute points."""
return floor(
sum(value for value in get_attribute_points(user, content, attribute).values())
+3 -3
View File
@@ -362,7 +362,7 @@
},
"addons": {
"name": "Add-ons",
"description": "List of add-ons to include in the backup. Use the name slug of each add-on."
"description": "List of add-ons to include in the backup. Use the name slug of the add-on."
},
"folders": {
"name": "Folders",
@@ -418,11 +418,11 @@
},
"folders": {
"name": "[%key:component::hassio::services::backup_partial::fields::folders::name%]",
"description": "List of directories to restore from the backup."
"description": "[%key:component::hassio::services::backup_partial::fields::folders::description%]"
},
"addons": {
"name": "[%key:component::hassio::services::backup_partial::fields::addons::name%]",
"description": "List of add-ons to restore from the backup. Use the name slug of each add-on."
"description": "[%key:component::hassio::services::backup_partial::fields::addons::description%]"
},
"password": {
"name": "[%key:common::config_flow::data::password%]",
+27 -59
View File
@@ -7,32 +7,17 @@ from dataclasses import dataclass
from datetime import timedelta
import logging
from pyheos import (
Credentials,
Heos,
HeosError,
HeosOptions,
HeosPlayer,
const as heos_const,
)
from pyheos import Heos, HeosError, HeosPlayer, const as heos_const
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from . import services
@@ -48,8 +33,6 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
MIN_UPDATE_SOURCES = timedelta(seconds=1)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
@@ -66,12 +49,6 @@ class HeosRuntimeData:
type HeosConfigEntry = ConfigEntry[HeosRuntimeData]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the HEOS component."""
services.register(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
"""Initialize config entry which represents the HEOS controller."""
# For backwards compat
@@ -79,37 +56,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
host = entry.data[CONF_HOST]
credentials: Credentials | None = None
if entry.options:
credentials = Credentials(
entry.options[CONF_USERNAME], entry.options[CONF_PASSWORD]
)
# Setting all_progress_events=False ensures that we only receive a
# media position update upon start of playback or when media changes
controller = Heos(
HeosOptions(
host,
all_progress_events=False,
auto_reconnect=True,
credentials=credentials,
)
)
# Auth failure handler must be added before connecting to the host, otherwise
# the event will be missed when login fails during connection.
async def auth_failure(event: str) -> None:
"""Handle authentication failure."""
if event == heos_const.EVENT_USER_CREDENTIALS_INVALID:
entry.async_start_reauth(hass)
entry.async_on_unload(
controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, auth_failure)
)
controller = Heos(host, all_progress_events=False)
try:
# Auto reconnect only operates if initial connection was successful.
await controller.connect()
await controller.connect(auto_reconnect=True)
# Auto reconnect only operates if initial connection was successful.
except HeosError as error:
await controller.disconnect()
_LOGGER.debug("Unable to connect to controller %s: %s", host, error)
@@ -131,7 +83,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
favorites = await controller.get_favorites()
else:
_LOGGER.warning(
"The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services"
(
"%s is not logged in to a HEOS account and will be unable to"
" retrieve HEOS favorites: Use the 'heos.sign_in' service to"
" sign-in to a HEOS account"
),
host,
)
inputs = await controller.get_input_sources()
except HeosError as error:
@@ -151,6 +108,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
controller_manager, group_manager, source_manager, players
)
services.register(hass, controller)
group_manager.connect_update()
entry.async_on_unload(group_manager.disconnect_update)
@@ -162,6 +120,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.controller_manager.disconnect()
services.remove(hass)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -174,6 +135,7 @@ class ControllerManager:
self._device_registry = None
self._entity_registry = None
self.controller = controller
self._signals = []
async def connect_listeners(self):
"""Subscribe to events of interest."""
@@ -181,17 +143,23 @@ class ControllerManager:
self._entity_registry = er.async_get(self._hass)
# Handle controller events
self.controller.dispatcher.connect(
heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event
self._signals.append(
self.controller.dispatcher.connect(
heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event
)
)
# Handle connection-related events
self.controller.dispatcher.connect(
heos_const.SIGNAL_HEOS_EVENT, self._heos_event
self._signals.append(
self.controller.dispatcher.connect(
heos_const.SIGNAL_HEOS_EVENT, self._heos_event
)
)
async def disconnect(self):
"""Disconnect subscriptions."""
for signal_remove in self._signals:
signal_remove()
self._signals.clear()
self.controller.dispatcher.disconnect_all()
await self.controller.disconnect()
+5 -128
View File
@@ -1,37 +1,17 @@
"""Config flow to configure Heos."""
from collections.abc import Mapping
import logging
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from pyheos import CommandFailedError, Heos, HeosError, HeosOptions
from pyheos import Heos, HeosError
import voluptuous as vol
from homeassistant.components import ssdp
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import selector
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
AUTH_SCHEMA = vol.Schema(
{
vol.Optional(CONF_USERNAME): selector.TextSelector(),
vol.Optional(CONF_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
}
)
def format_title(host: str) -> str:
"""Format the title for config entries."""
@@ -40,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(HeosOptions(host, events=False, heart_beat=False))
heos = Heos(host)
try:
await heos.connect()
except HeosError:
@@ -51,65 +31,11 @@ async def _validate_host(host: str, errors: dict[str, str]) -> bool:
return True
async def _validate_auth(
user_input: dict[str, str], heos: Heos, errors: dict[str, str]
) -> bool:
"""Validate authentication by signing in or out, otherwise populate errors if needed."""
if not user_input:
# Log out (neither username nor password provided)
try:
await heos.sign_out()
except HeosError:
errors["base"] = "unknown"
_LOGGER.exception("Unexpected error occurred during sign-out")
return False
else:
_LOGGER.debug("Successfully signed-out of HEOS Account")
return True
# Ensure both username and password are provided
authentication = CONF_USERNAME in user_input or CONF_PASSWORD in user_input
if authentication and CONF_USERNAME not in user_input:
errors[CONF_USERNAME] = "username_missing"
return False
if authentication and CONF_PASSWORD not in user_input:
errors[CONF_PASSWORD] = "password_missing"
return False
# Attempt to login (both username and password provided)
try:
await heos.sign_in(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
except CommandFailedError as err:
if err.error_id in (6, 8, 10): # Auth-specific errors
errors["base"] = "invalid_auth"
_LOGGER.warning("Failed to sign-in to HEOS Account: %s", err)
else:
errors["base"] = "unknown"
_LOGGER.exception("Unexpected error occurred during sign-in")
return False
except HeosError:
errors["base"] = "unknown"
_LOGGER.exception("Unexpected error occurred during sign-in")
return False
else:
_LOGGER.debug(
"Successfully signed-in to HEOS Account: %s",
heos.signed_in_username,
)
return True
class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
"""Define a flow for HEOS."""
VERSION = 1
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Create the options flow."""
return HeosOptionsFlowHandler()
async def async_step_ssdp(
self, discovery_info: ssdp.SsdpServiceInfo
) -> ConfigFlowResult:
@@ -174,52 +100,3 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauthentication after auth failure event."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Validate account credentials and update options."""
errors: dict[str, str] = {}
entry = self._get_reauth_entry()
if user_input is not None:
heos = cast(Heos, entry.runtime_data.controller_manager.controller)
if await _validate_auth(user_input, heos, errors):
return self.async_update_reload_and_abort(entry, options=user_input)
return self.async_show_form(
step_id="reauth_confirm",
errors=errors,
data_schema=self.add_suggested_values_to_schema(
AUTH_SCHEMA, user_input or entry.options
),
)
class HeosOptionsFlowHandler(OptionsFlow):
"""Define HEOS options flow."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
errors: dict[str, str] = {}
if user_input is not None:
heos = cast(
Heos, self.config_entry.runtime_data.controller_manager.controller
)
if await _validate_auth(user_input, heos, errors):
return self.async_create_entry(data=user_input)
return self.async_show_form(
errors=errors,
step_id="init",
data_schema=self.add_suggested_values_to_schema(
AUTH_SCHEMA, user_input or self.config_entry.options
),
)
+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.8.0"],
"requirements": ["pyheos==0.7.2"],
"single_config_entry": true,
"ssdp": [
{
@@ -123,6 +123,7 @@ class HeosMediaPlayer(MediaPlayerEntity):
"""Initialize."""
self._media_position_updated_at = None
self._player = player
self._signals: list = []
self._source_manager = source_manager
self._group_manager = group_manager
self._attr_unique_id = str(player.player_id)
@@ -149,13 +150,13 @@ class HeosMediaPlayer(MediaPlayerEntity):
async def async_added_to_hass(self) -> None:
"""Device added to hass."""
# Update state when attributes of the player change
self.async_on_remove(
self._signals.append(
self._player.heos.dispatcher.connect(
heos_const.SIGNAL_PLAYER_EVENT, self._player_update
)
)
# Update state when heos changes
self.async_on_remove(
self._signals.append(
async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated)
)
# Register this player's entity_id so it can be resolved by the group manager
@@ -303,6 +304,12 @@ class HeosMediaPlayer(MediaPlayerEntity):
self._player.player_id, self.entity_id
)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect the device when removed."""
for signal_remove in self._signals:
signal_remove()
self._signals.clear()
@property
def available(self) -> bool:
"""Return True if the device is available."""
@@ -1,6 +1,8 @@
rules:
# Bronze
action-setup: done
action-setup:
status: todo
comment: Future enhancement to move custom actions for login/out into an options flow.
appropriate-polling:
status: done
comment: Integration is a local push integration
@@ -15,7 +17,11 @@ rules:
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: todo
entity-event-setup: done
entity-event-setup:
status: todo
comment: |
Simplify by using async_on_remove instead of keeping track of listeners to remove
later in async_will_remove_from_hass.
entity-unique-id: done
has-entity-name: done
runtime-data: done
@@ -27,7 +33,10 @@ rules:
status: todo
comment: Actions currently only log and instead should raise exceptions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-configuration-parameters:
status: done
comment: |
The integration doesn't provide any additional configuration parameters.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
@@ -38,7 +47,10 @@ rules:
parallel-updates:
status: todo
comment: Needs to be set to 0. The underlying library handles parallel updates.
reauthentication-flow: done
reauthentication-flow:
status: exempt
comment: |
This integration doesn't require re-authentication.
test-coverage:
status: todo
comment: |
+11 -34
View File
@@ -1,14 +1,13 @@
"""Services for the HEOS integration."""
import functools
import logging
from pyheos import CommandFailedError, Heos, HeosError, const
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_PASSWORD,
@@ -27,50 +26,30 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema(
HEOS_SIGN_OUT_SCHEMA = vol.Schema({})
def register(hass: HomeAssistant):
def register(hass: HomeAssistant, controller: Heos):
"""Register HEOS services."""
hass.services.async_register(
DOMAIN,
SERVICE_SIGN_IN,
_sign_in_handler,
functools.partial(_sign_in_handler, controller),
schema=HEOS_SIGN_IN_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_SIGN_OUT,
_sign_out_handler,
functools.partial(_sign_out_handler, controller),
schema=HEOS_SIGN_OUT_SCHEMA,
)
def _get_controller(hass: HomeAssistant) -> Heos:
"""Get the HEOS controller instance."""
_LOGGER.warning(
"Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release"
)
ir.async_create_issue(
hass,
DOMAIN,
"sign_in_out_deprecated",
breaks_in_ha_version="2025.8.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="sign_in_out_deprecated",
)
entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN)
if not entry or not entry.state == ConfigEntryState.LOADED:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="integration_not_loaded"
)
return entry.runtime_data.controller_manager.controller
def remove(hass: HomeAssistant):
"""Unregister HEOS services."""
hass.services.async_remove(DOMAIN, SERVICE_SIGN_IN)
hass.services.async_remove(DOMAIN, SERVICE_SIGN_OUT)
async def _sign_in_handler(service: ServiceCall) -> None:
async def _sign_in_handler(controller: Heos, service: ServiceCall) -> None:
"""Sign in to the HEOS account."""
controller = _get_controller(service.hass)
if controller.connection_state != const.STATE_CONNECTED:
_LOGGER.error("Unable to sign in because HEOS is not connected")
return
@@ -84,10 +63,8 @@ async def _sign_in_handler(service: ServiceCall) -> None:
_LOGGER.error("Unable to sign in: %s", err)
async def _sign_out_handler(service: ServiceCall) -> None:
async def _sign_out_handler(controller: Heos, service: ServiceCall) -> None:
"""Sign out of the HEOS account."""
controller = _get_controller(service.hass)
if controller.connection_state != const.STATE_CONNECTED:
_LOGGER.error("Unable to sign out because HEOS is not connected")
return
+1 -51
View File
@@ -20,56 +20,17 @@
"data_description": {
"host": "[%key:component::heos::config::step::user::data_description::host%]"
}
},
"reauth_confirm": {
"title": "Reauthenticate HEOS",
"description": "Please update your HEOS Account credentials. Alternatively, you can clear the credentials if you do not want the integration to access favorites, playlists, and streaming services.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "[%key:component::heos::options::step::init::data_description::username%]",
"password": "[%key:component::heos::options::step::init::data_description::password%]"
}
}
},
"error": {
"username_missing": "[%key:component::heos::options::error::username_missing%]",
"password_missing": "[%key:component::heos::options::error::password_missing%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
}
},
"options": {
"step": {
"init": {
"title": "HEOS Options",
"description": "You can sign-in to your HEOS Account to access favorites, streaming services, and other features. Clearing the credentials will sign-out of your account.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "The username or email address of your HEOS Account.",
"password": "The password to your HEOS Account."
}
}
},
"error": {
"username_missing": "Username is missing",
"password_missing": "Password is missing",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"services": {
"sign_in": {
"name": "Sign in",
@@ -89,16 +50,5 @@
"name": "Sign out",
"description": "Signs out of the HEOS account."
}
},
"exceptions": {
"integration_not_loaded": {
"message": "The HEOS integration is not loaded"
}
},
"issues": {
"sign_in_out_deprecated": {
"title": "HEOS Actions Deprecated",
"description": "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release. Enter your HEOS Account credentials in the configuration options and the integration will manage authentication automatically."
}
}
}
@@ -23,7 +23,7 @@
},
"exceptions": {
"appliance_not_found": {
"message": "Appliance for device ID {device_id} not found"
"message": "Appliance for device id {device_id} not found"
},
"turn_on_light": {
"message": "Error turning on {entity_id}: {description}"
@@ -103,7 +103,7 @@
"fields": {
"device_id": {
"name": "Device ID",
"description": "ID of the device."
"description": "Id of the device."
},
"program": { "name": "Program", "description": "Program to select." },
"key": { "name": "Option key", "description": "Key of the option." },
@@ -7,12 +7,17 @@ import asyncio
import logging
from typing import Any
from universal_silabs_flasher.const import ApplicationType
from homeassistant.components.hassio import (
AddonError,
AddonInfo,
AddonManager,
AddonState,
)
from homeassistant.components.zha.repairs.wrong_silabs_firmware import (
probe_silabs_firmware_type,
)
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
@@ -27,11 +32,9 @@ from homeassistant.helpers.hassio import is_hassio
from . import silabs_multiprotocol_addon
from .const import ZHA_DOMAIN
from .util import (
ApplicationType,
get_otbr_addon_manager,
get_zha_device_path,
get_zigbee_flasher_addon_manager,
probe_silabs_firmware_type,
)
_LOGGER = logging.getLogger(__name__)
@@ -1,9 +1,8 @@
{
"domain": "homeassistant_hardware",
"name": "Home Assistant Hardware",
"after_dependencies": ["hassio"],
"after_dependencies": ["hassio", "zha"],
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": ["universal-silabs-flasher==0.0.25"]
"integration_type": "system"
}
@@ -3,14 +3,11 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass
from enum import StrEnum
import logging
from typing import cast
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.flasher import Flasher
from universal_silabs_flasher.const import ApplicationType
from homeassistant.components.hassio import AddonError, AddonState
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
@@ -35,26 +32,6 @@ from .silabs_multiprotocol_addon import (
_LOGGER = logging.getLogger(__name__)
class ApplicationType(StrEnum):
"""Application type running on a device."""
GECKO_BOOTLOADER = "bootloader"
CPC = "cpc"
EZSP = "ezsp"
SPINEL = "spinel"
@classmethod
def from_flasher_application_type(
cls, app_type: FlasherApplicationType
) -> ApplicationType:
"""Convert a USF application type enum."""
return cls(app_type.value)
def as_flasher_application_type(self) -> FlasherApplicationType:
"""Convert the application type enum into one compatible with USF."""
return FlasherApplicationType(self.value)
def get_zha_device_path(config_entry: ConfigEntry) -> str | None:
"""Get the device path from a ZHA config entry."""
return cast(str | None, config_entry.data.get("device", {}).get("path", None))
@@ -160,27 +137,3 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware
assert guesses
return guesses[-1]
async def probe_silabs_firmware_type(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
) -> ApplicationType | None:
"""Probe the running firmware on a Silabs device."""
flasher = Flasher(
device=device,
**(
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]}
if probe_methods
else {}
),
)
try:
await flasher.probe_app_type()
except Exception: # noqa: BLE001
_LOGGER.debug("Failed to probe application type", exc_info=True)
if flasher.app_type is None:
return None
return ApplicationType.from_flasher_application_type(flasher.app_type)
@@ -5,12 +5,13 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Protocol
from universal_silabs_flasher.const import ApplicationType
from homeassistant.components import usb
from homeassistant.components.homeassistant_hardware import (
firmware_config_flow,
silabs_multiprotocol_addon,
)
from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
@@ -8,6 +8,7 @@ import logging
from typing import Any, final
import aiohttp
from universal_silabs_flasher.const import ApplicationType
import voluptuous as vol
from homeassistant.components.hassio import (
@@ -24,7 +25,6 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
OptionsFlowHandler as MultiprotocolOptionsFlowHandler,
SerialPortSettings as MultiprotocolSerialPortSettings,
)
from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.config_entries import (
SOURCE_HARDWARE,
ConfigEntry,
@@ -1,85 +0,0 @@
"""The Homee integration."""
import logging
from pyHomee import Homee, HomeeAuthFailedException, HomeeConnectionFailedException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.COVER]
type HomeeConfigEntry = ConfigEntry[Homee]
async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool:
"""Set up homee from a config entry."""
# Create the Homee api object using host, user,
# password & pyHomee instance from the config
homee = Homee(
host=entry.data[CONF_HOST],
user=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
device="HA_" + hass.config.location_name,
reconnect_interval=10,
max_retries=100,
)
# Start the homee websocket connection as a new task
# and wait until we are connected
try:
await homee.get_access_token()
except HomeeConnectionFailedException as exc:
raise ConfigEntryNotReady(
f"Connection to Homee failed: {exc.__cause__}"
) from exc
except HomeeAuthFailedException as exc:
raise ConfigEntryNotReady(
f"Authentication to Homee failed: {exc.__cause__}"
) from exc
hass.loop.create_task(homee.run())
await homee.wait_until_connected()
entry.runtime_data = homee
entry.async_on_unload(homee.disconnect)
async def _connection_update_callback(connected: bool) -> None:
"""Call when the device is notified of changes."""
if connected:
_LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST])
else:
_LOGGER.warning("Disconnected from Homee at %s", entry.data[CONF_HOST])
await homee.add_connection_listener(_connection_update_callback)
# create device register entry
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={
(dr.CONNECTION_NETWORK_MAC, dr.format_mac(homee.settings.mac_address))
},
identifiers={(DOMAIN, homee.settings.uid)},
manufacturer="homee",
name=homee.settings.homee_name,
model="homee",
sw_version=homee.settings.version,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool:
"""Unload a homee config entry."""
# Unload platforms
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,85 +0,0 @@
"""Config flow for homee integration."""
import logging
from typing import Any
from pyHomee import (
Homee,
HomeeAuthFailedException as HomeeAuthenticationFailedException,
HomeeConnectionFailedException,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
AUTH_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for homee."""
VERSION = 1
homee: Homee
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial user step."""
errors = {}
if user_input is not None:
self.homee = Homee(
user_input[CONF_HOST],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
try:
await self.homee.get_access_token()
except HomeeConnectionFailedException:
errors["base"] = "cannot_connect"
except HomeeAuthenticationFailedException:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
_LOGGER.info("Got access token for homee")
self.hass.loop.create_task(self.homee.run())
_LOGGER.debug("Homee task created")
await self.homee.wait_until_connected()
_LOGGER.info("Homee connected")
self.homee.disconnect()
_LOGGER.debug("Homee disconnecting")
await self.homee.wait_until_disconnected()
_LOGGER.info("Homee config successfully tested")
await self.async_set_unique_id(self.homee.settings.uid)
self._abort_if_unique_id_configured()
_LOGGER.info(
"Created new homee entry with ID %s", self.homee.settings.uid
)
return self.async_create_entry(
title=f"{self.homee.settings.homee_name} ({self.homee.host})",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=AUTH_SCHEMA,
errors=errors,
)
-4
View File
@@ -1,4 +0,0 @@
"""Constants for the homee integration."""
# General
DOMAIN = "homee"
-261
View File
@@ -1,261 +0,0 @@
"""The homee cover platform."""
import logging
from typing import Any, cast
from pyHomee.const import AttributeType, NodeProfile
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.cover import (
ATTR_POSITION,
ATTR_TILT_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeNodeEntity
_LOGGER = logging.getLogger(__name__)
OPEN_CLOSE_ATTRIBUTES = [
AttributeType.OPEN_CLOSE,
AttributeType.SLAT_ROTATION_IMPULSE,
AttributeType.UP_DOWN,
]
POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION]
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute:
"""Return the attribute used for opening/closing the cover."""
# We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them.
if (open_close := node.get_attribute_by_type(AttributeType.UP_DOWN)) is None:
open_close = node.get_attribute_by_type(AttributeType.OPEN_CLOSE)
return open_close
def get_cover_features(
node: HomeeNode, open_close_attribute: HomeeAttribute
) -> CoverEntityFeature:
"""Determine the supported cover features of a homee node based on the available attributes."""
features = CoverEntityFeature(0)
if open_close_attribute.editable:
features |= (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
)
# Check for up/down position settable.
attribute = node.get_attribute_by_type(AttributeType.POSITION)
if attribute is not None:
if attribute.editable:
features |= CoverEntityFeature.SET_POSITION
if node.get_attribute_by_type(AttributeType.SLAT_ROTATION_IMPULSE) is not None:
features |= CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
if node.get_attribute_by_type(AttributeType.SHUTTER_SLAT_POSITION) is not None:
features |= CoverEntityFeature.SET_TILT_POSITION
return features
def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
"""Determine the device class a homee node based on the node profile."""
COVER_DEVICE_PROFILES = {
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
}
return COVER_DEVICE_PROFILES.get(node.profile)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_devices: AddEntitiesCallback,
) -> None:
"""Add the homee platform for the cover integration."""
async_add_devices(
HomeeCover(node, config_entry)
for node in config_entry.runtime_data.nodes
if is_cover_node(node)
)
def is_cover_node(node: HomeeNode) -> bool:
"""Determine if a node is controllable as a homee cover based on its profile and attributes."""
return node.profile in [
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH,
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION,
NodeProfile.GARAGE_DOOR_OPERATOR,
NodeProfile.SHUTTER_POSITION_SWITCH,
]
class HomeeCover(HomeeNodeEntity, CoverEntity):
"""Representation of a homee cover device."""
_attr_name = None
def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
"""Initialize a homee cover entity."""
super().__init__(node, entry)
self._open_close_attribute = get_open_close_attribute(node)
self._attr_supported_features = get_cover_features(
node, self._open_close_attribute
)
self._attr_device_class = get_device_class(node)
self._attr_unique_id = f"{self._attr_unique_id}-{self._open_close_attribute.id}"
@property
def current_cover_position(self) -> int | None:
"""Return the cover's position."""
# Translate the homee position values to HA's 0-100 scale
if self.has_attribute(AttributeType.POSITION):
attribute = self._node.get_attribute_by_type(AttributeType.POSITION)
homee_min = attribute.minimum
homee_max = attribute.maximum
homee_position = attribute.current_value
position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100
return 100 - position
return None
@property
def current_cover_tilt_position(self) -> int | None:
"""Return the cover's tilt position."""
# Translate the homee position values to HA's 0-100 scale
if self.has_attribute(AttributeType.SHUTTER_SLAT_POSITION):
attribute = self._node.get_attribute_by_type(
AttributeType.SHUTTER_SLAT_POSITION
)
homee_min = attribute.minimum
homee_max = attribute.maximum
homee_position = attribute.current_value
position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100
return 100 - position
return None
@property
def is_opening(self) -> bool | None:
"""Return the opening status of the cover."""
if self._open_close_attribute is not None:
return (
self._open_close_attribute.get_value() == 3
if not self._open_close_attribute.is_reversed
else self._open_close_attribute.get_value() == 4
)
return None
@property
def is_closing(self) -> bool | None:
"""Return the closing status of the cover."""
if self._open_close_attribute is not None:
return (
self._open_close_attribute.get_value() == 4
if not self._open_close_attribute.is_reversed
else self._open_close_attribute.get_value() == 3
)
return None
@property
def is_closed(self) -> bool | None:
"""Return if the cover is closed."""
if self.has_attribute(AttributeType.POSITION):
attribute = self._node.get_attribute_by_type(AttributeType.POSITION)
return attribute.get_value() == attribute.maximum
if self._open_close_attribute is not None:
if not self._open_close_attribute.is_reversed:
return self._open_close_attribute.get_value() == 1
return self._open_close_attribute.get_value() == 0
# If none of the above is present, it might be a slat only cover.
if self.has_attribute(AttributeType.SHUTTER_SLAT_POSITION):
attribute = self._node.get_attribute_by_type(
AttributeType.SHUTTER_SLAT_POSITION
)
return attribute.get_value() == attribute.minimum
return None
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
if not self._open_close_attribute.is_reversed:
await self.async_set_value(self._open_close_attribute, 0)
else:
await self.async_set_value(self._open_close_attribute, 1)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
if not self._open_close_attribute.is_reversed:
await self.async_set_value(self._open_close_attribute, 1)
else:
await self.async_set_value(self._open_close_attribute, 0)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
if CoverEntityFeature.SET_POSITION in self.supported_features:
position = 100 - cast(int, kwargs[ATTR_POSITION])
# Convert position to range of our entity.
attribute = self._node.get_attribute_by_type(AttributeType.POSITION)
homee_min = attribute.minimum
homee_max = attribute.maximum
homee_position = (position / 100) * (homee_max - homee_min) + homee_min
await self.async_set_value(AttributeType.POSITION, homee_position)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self.async_set_value(self._open_close_attribute, 2)
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt."""
slat_attribute = self._node.get_attribute_by_type(
AttributeType.SLAT_ROTATION_IMPULSE
)
if not slat_attribute.is_reversed:
await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2)
else:
await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt."""
slat_attribute = self._node.get_attribute_by_type(
AttributeType.SLAT_ROTATION_IMPULSE
)
if not slat_attribute.is_reversed:
await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1)
else:
await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2)
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
if CoverEntityFeature.SET_TILT_POSITION in self.supported_features:
position = 100 - cast(int, kwargs[ATTR_TILT_POSITION])
# Convert position to range of our entity.
attribute = self._node.get_attribute_by_type(
AttributeType.SHUTTER_SLAT_POSITION
)
homee_min = attribute.minimum
homee_max = attribute.maximum
homee_position = (position / 100) * (homee_max - homee_min) + homee_min
await self.async_set_value(
AttributeType.SHUTTER_SLAT_POSITION, homee_position
)
-88
View File
@@ -1,88 +0,0 @@
"""Base Entities for Homee integration."""
from pyHomee.const import AttributeType, NodeProfile, NodeState
from pyHomee.model import HomeeNode
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from . import HomeeConfigEntry
from .const import DOMAIN
from .helpers import get_name_for_enum
class HomeeNodeEntity(Entity):
"""Representation of an Entity that uses more than one HomeeAttribute."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
"""Initialize the wrapper using a HomeeNode and target entity."""
self._node = node
self._attr_unique_id = f"{entry.runtime_data.settings.uid}-{node.id}"
self._entry = entry
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(node.id))},
name=node.name,
model=get_name_for_enum(NodeProfile, node.profile),
sw_version=self._get_software_version(),
via_device=(DOMAIN, entry.runtime_data.settings.uid),
)
self._host_connected = entry.runtime_data.connected
async def async_added_to_hass(self) -> None:
"""Add the homee binary sensor device to home assistant."""
self.async_on_remove(self._node.add_on_changed_listener(self._on_node_updated))
self.async_on_remove(
await self._entry.runtime_data.add_connection_listener(
self._on_connection_changed
)
)
@property
def available(self) -> bool:
"""Return the availability of the underlying node."""
return self._node.state == NodeState.AVAILABLE and self._host_connected
async def async_update(self) -> None:
"""Fetch new state data for this node."""
# Base class requests the whole node, if only a single attribute is needed
# the platform will overwrite this method.
homee = self._entry.runtime_data
await homee.update_node(self._node.id)
def _get_software_version(self) -> str | None:
"""Return the software version of the node."""
if self.has_attribute(AttributeType.FIRMWARE_REVISION):
return self._node.get_attribute_by_type(
AttributeType.FIRMWARE_REVISION
).get_value()
if self.has_attribute(AttributeType.SOFTWARE_REVISION):
return self._node.get_attribute_by_type(
AttributeType.SOFTWARE_REVISION
).get_value()
return None
def has_attribute(self, attribute_type: AttributeType) -> bool:
"""Check if an attribute of the given type exists."""
return attribute_type in self._node.attribute_map
async def async_set_value(self, attribute_type: int, value: float) -> None:
"""Set an attribute value on the homee node."""
await self.async_set_value_by_id(
self._node.get_attribute_by_type(attribute_type).id, value
)
async def async_set_value_by_id(self, attribute_id: int, value: float) -> None:
"""Set an attribute value on the homee node."""
homee = self._entry.runtime_data
await homee.set_value(self._node.id, attribute_id, value)
def _on_node_updated(self, node: HomeeNode) -> None:
self.schedule_update_ha_state()
async def _on_connection_changed(self, connected: bool) -> None:
self._host_connected = connected
self.schedule_update_ha_state()
-16
View File
@@ -1,16 +0,0 @@
"""Helper functions for the homee custom component."""
import logging
_LOGGER = logging.getLogger(__name__)
def get_name_for_enum(att_class, att_id) -> str:
"""Return the enum item name for a given integer."""
try:
attribute_name = att_class(att_id).name
except ValueError:
_LOGGER.warning("Value %s does not exist in %s", att_id, att_class.__name__)
return "Unknown"
return attribute_name
@@ -1,12 +0,0 @@
{
"domain": "homee",
"name": "Homee",
"codeowners": ["@Taraman17"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homee",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["homee"],
"quality_scale": "bronze",
"requirements": ["pyHomee==1.2.0"]
}
@@ -1,68 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
The integration does not provide any additional actions.
appropriate-polling:
status: exempt
comment: Integration is push based.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
The integration does not provide any additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
@@ -1,28 +0,0 @@
{
"config": {
"flow_title": "Homee {name} ({host})",
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"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%]"
},
"step": {
"user": {
"title": "Configure homee",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The IP address of your Homee.",
"username": "The username for your Homee.",
"password": "The password for your Homee."
}
}
}
}
}
@@ -12,6 +12,5 @@
"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: done
docs-high-level-description: todo
docs-installation-instructions: done
docs-removal-instructions: done
docs-removal-instructions: todo
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
@@ -1,61 +0,0 @@
"""The igloohome integration."""
from __future__ import annotations
from dataclasses import dataclass
from aiohttp import ClientError
from igloohome_api import (
Api as IgloohomeApi,
ApiException,
Auth as IgloohomeAuth,
AuthException,
GetDeviceInfoResponse,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
PLATFORMS: list[Platform] = [Platform.SENSOR]
@dataclass
class IgloohomeRuntimeData:
"""Holding class for runtime data."""
api: IgloohomeApi
devices: list[GetDeviceInfoResponse]
type IgloohomeConfigEntry = ConfigEntry[IgloohomeRuntimeData]
async def async_setup_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool:
"""Set up igloohome from a config entry."""
authentication = IgloohomeAuth(
session=async_get_clientsession(hass),
client_id=entry.data[CONF_CLIENT_ID],
client_secret=entry.data[CONF_CLIENT_SECRET],
)
api = IgloohomeApi(auth=authentication)
try:
devices = (await api.get_devices()).payload
except AuthException as e:
raise ConfigEntryError from e
except (ApiException, ClientError) as e:
raise ConfigEntryNotReady from e
entry.runtime_data = IgloohomeRuntimeData(api, devices)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,61 +0,0 @@
"""Config flow for igloohome integration."""
from __future__ import annotations
import logging
from typing import Any
from aiohttp import ClientError
from igloohome_api import Auth as IgloohomeAuth, AuthException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_CLIENT_ID): str,
vol.Required(CONF_CLIENT_SECRET): str,
}
)
class IgloohomeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for igloohome."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the config flow step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{
CONF_CLIENT_ID: user_input[CONF_CLIENT_ID],
}
)
auth = IgloohomeAuth(
session=async_get_clientsession(self.hass),
client_id=user_input[CONF_CLIENT_ID],
client_secret=user_input[CONF_CLIENT_SECRET],
)
try:
await auth.async_get_access_token()
except AuthException:
errors["base"] = "invalid_auth"
except ClientError:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title="Client Credentials", data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

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