Compare commits

...
Sign in to create a new pull request.

203 commits

Author SHA1 Message Date
Josef Zweck
1ce8bfdaa4
Use test helpers for acaia buttons (#130626) 2024-11-14 16:34:17 +01:00
Robert Resch
cd12720085
Add Python version to issue ID (#130611) 2024-11-14 16:31:33 +01:00
epenet
c7ee7dc880
Refactor translation checks (#130585)
* Refactor translation checks

* Adjust

* Improve

* Restore await

* Delay pytest.fail until the end of the test
2024-11-14 16:26:05 +01:00
epenet
472414a8d6
Add missing translation string to smarty (#130624) 2024-11-14 16:17:08 +01:00
Lennard Beers
0c44c632d4
Add number platform to eq3btsmart (#130429) 2024-11-14 15:38:38 +01:00
Álvaro Fernández Rojas
61d0de3042
Bump aioairzone to 0.9.6 (#130559)
* Update aioairzone to v0.9.6

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Remove _async_migrator_mac_empty and improve tests

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Remove WebServer empty mac fixes as requested by @epenet

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

---------

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>
2024-11-14 15:27:10 +01:00
Thibaut
01332a542c
Removing myself from template codeowners (#130617)
* Removing myself as codeowners

* Fix

---------

Co-authored-by: Joostlek <joostlek@outlook.com>
2024-11-14 15:23:55 +01:00
Andre Lengwenus
3d84e35268
Move lcn non-config_entry related code to async_setup (#130603)
* Move non-config_entry related code to async_setup

* Remove action unload
2024-11-14 14:27:19 +01:00
Josef Zweck
eea782bbfe
Add acaia integration (#130059)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-11-14 13:28:38 +01:00
Lennard Beers
a949d18c30
Bump eq3btsmart to 1.4.1 (#130426) 2024-11-14 13:04:22 +01:00
Marc Mueller
a748897bd2
Update hassfest image to Python 3.13 (#130607) 2024-11-14 12:44:06 +01:00
Robert Resch
3201142fd8
Fix hassfest by adding go2rtc reqs (#130602) 2024-11-14 11:01:26 +01:00
starkillerOG
d0a58b68e8
Bump reolink-aio to 0.11.1 (#130600) 2024-11-14 10:48:25 +01:00
Simone Chemelli
93f79be2f4
Update uptime deviation for Vodafone Station (#130571)
Update sensor.py
2024-11-14 10:35:03 +01:00
Robert Resch
46cfe6aa32
Refactor camera WebRTC tests (#130581) 2024-11-14 10:28:04 +01:00
Robert Resch
301043ec38
Add require_webrtc_support decorator (#130519) 2024-11-14 10:27:45 +01:00
puddly
245fc246d8
Ensure ZHA setup works with container installs (#130470) 2024-11-14 10:13:29 +01:00
Noah Husby
58fd917cb7
Disable brightness from devices with no display in Cambridge Audio (#130369) 2024-11-14 10:11:44 +01:00
Steven B.
2c1d1f5777
Do not trigger events for updated ring events (#130430) 2024-11-14 10:09:58 +01:00
Luke Lashley
938b1eca22
Fix when the Roborock map is being provisioned (#130574) 2024-11-14 09:52:28 +01:00
Brett Adams
2fda4c82de
Force login prompt in Tesla Fleet (#130576) 2024-11-14 09:46:24 +01:00
J. Nick Koston
4200913d03
Fix non-thread-safe operation in powerview number (#130557) 2024-11-14 09:45:08 +01:00
Tony
4aad614497
Bump aioruckus to 0.42 (#130487) 2024-11-14 09:43:59 +01:00
epenet
6a3b4a6a23
Adjust minimum scapy version to 2.6.1 (#130565) 2024-11-13 17:49:39 -06:00
Michael Hansen
51c6ee97b1
Upgrade to hassil 2.0 (#130544)
* Working on hassil 2.0

* Bump to hassil 2.0

* Update snapshots

* Remove debug logging
2024-11-13 16:50:08 -05:00
Simon Lamon
4002bc3c25
Downgrade devcontainer to Python 3.12 again (#130562) 2024-11-13 22:03:34 +01:00
J. Nick Koston
c35ef6bda3
Bump aiohttp to 3.11.0 (#130542) 2024-11-13 19:32:14 +01:00
Marc Mueller
ed5560aec2
Update base image to Python 3.13 and deprecated 3.12 (#130425) 2024-11-13 19:28:53 +01:00
Sheldon Ip
0a5a2de78e
Fix translations in subaru (#130486) 2024-11-13 18:46:52 +01:00
Brig Lamoreaux
7fd337d67f
fix translation in srp_energy (#130540) 2024-11-13 18:42:26 +01:00
Marc Mueller
5f68d405b2
Update huum to 0.7.12 (#130527) 2024-11-13 17:26:27 +01:00
Erik Montnemery
093b16c723
Make WS command backup/generate send events (#130524)
* Make WS command backup/generate send events

* Update backup.create service
2024-11-13 16:16:49 +01:00
Steven B.
ac4cb52dbb
Bump ring-doorbell to 0.9.12 (#130419) 2024-11-13 08:04:23 -06:00
dunnmj
72b976f832
Add Sky remote integration (#124507)
Co-authored-by: Kyle Cooke <saty9@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-11-13 14:29:04 +01:00
Daniel Hjelseth Høyer
f6bc5f050e
Bump millheater to 0.12.2 (#130454) 2024-11-13 14:28:19 +01:00
epenet
8300afc00d
Improve type hints in fritz config flow (#130511)
* Improve type hints in fritz config flow

* Improve coverage

* Apply suggestions from code review

Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>

---------

Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>
2024-11-13 13:45:52 +01:00
epenet
ab11b84678
Improve type hints in fritzbox config flow (#130509) 2024-11-13 13:01:54 +01:00
Joost Lekkerkerker
b78453b85b
Bump aiowithings to 3.1.3 (#130504) 2024-11-13 12:21:15 +01:00
Joost Lekkerkerker
b270e4556c
Avoid core manifest to have an issue tracker (#130514) 2024-11-13 12:16:07 +01:00
Joost Lekkerkerker
e90893e2bc
Fix Music Assistant manifest (#130515) 2024-11-13 11:43:31 +01:00
dependabot[bot]
a06e7e31b9
Bump github/codeql-action from 3.27.1 to 3.27.3 (#130489)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.27.1 to 3.27.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3.27.1...v3.27.3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 11:06:38 +01:00
Robert Resch
2eaaadd736
Add go2rtc recommended version (#130508) 2024-11-13 11:01:05 +01:00
G Johansson
0ac00ef092
Fix legacy _attr_state handling in AlarmControlPanel (#130479) 2024-11-13 10:55:28 +01:00
Robert Resch
3092297979
Bump go2rtc-client to 0.1.1 (#130498) 2024-11-13 09:55:52 +01:00
Thomas55555
827875473b
Fix RecursionError in Husqvarna Automower coordinator (#123085)
* reach maximum recursion depth exceeded in tests

* second background task

* Update homeassistant/components/husqvarna_automower/coordinator.py

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

* Update homeassistant/components/husqvarna_automower/coordinator.py

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

* test

* modify test

* tests

* use correct exception

* reset mock

* use recursion_limit

* remove unneeded ticks

* test TimeoutException

* set lower recursionlimit

* remove not that important comment and move the other

* test that we connect and listen successfully

* Simulate hass shutting down

* skip testing against the recursion limit

* Update homeassistant/components/husqvarna_automower/coordinator.py

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

* mock

* Remove comment

* Revert "mock"

This reverts commit e8ddaea3d7.

* Move patch to decorator

* Make execution of patched methods predictable

* Parametrize test, make mocked start_listening block

* Apply suggestions from code review

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Erik <erik@montnemery.com>
2024-11-13 09:54:37 +01:00
Joost Lekkerkerker
5cce369ce8
Bump aiowithings to 3.1.2 (#130469) 2024-11-13 07:55:33 +01:00
Joost Lekkerkerker
fdb773c921
Add title to water heater component (#130446) 2024-11-13 07:55:13 +01:00
starkillerOG
8b505a2273
Bump reolink_aio to 0.11.0 (#130481) 2024-11-13 07:35:51 +01:00
Charles Garwood
a9f468509b
Bump zwave-js-server-python to 0.59.1 (#130468) 2024-11-13 07:14:39 +01:00
J. Nick Koston
4ff8b8015c
Bump aiohttp to 3.11.0rc2 (#130484) 2024-11-12 22:07:26 -06:00
mrspouse
5c52e865a0
Correct spelling of BloodGlucoseConcentrationConverter (#130449)
* Correct spelling of BloodGlucoseConcentrationConverter

* Correct spelling of BloodGlucoseConcentrationConverter
2024-11-12 21:48:42 +01:00
Kelvin Dekker
6bfc0cbb0c
Fix typo in file strings (#130465) 2024-11-12 21:33:52 +01:00
G Johansson
388473ecd7
Add diagnostics to Nord Pool (#130461) 2024-11-12 19:55:27 +01:00
G Johansson
285468d85f
Fix translation in statistics (#130455)
* Fix translation in statistics

* Update homeassistant/components/statistics/strings.json
2024-11-12 18:44:32 +01:00
epenet
167025a18c
Simplify modern_forms config flow (#130441)
* Simplify modern_forms config flow

* Rename variable

* Drop CONF_NAME
2024-11-12 18:03:37 +01:00
Joakim Sørensen
ac0c75a598
Add upload capability to the backup integration (#128546)
* Add upload capability to the backup integration

* Limit context switch

* rename

* coverage for http

* Test receiving a backup file

* Update test_manager.py

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

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2024-11-12 15:27:53 +01:00
Robert Resch
cb9cc0f801
Go2rtc bump and set ffmpeg logs to debug (#130371) 2024-11-12 11:53:14 +01:00
Lennard Beers
7758d8ba48
Add switch platform to eq3btsmart (#130363) 2024-11-12 11:42:25 +01:00
epenet
7045b776b6
Use report_usage in helpers (#130365) 2024-11-12 09:25:13 +01:00
J. Nick Koston
22aed92461
Bump aiohttp to 3.11.0rc1 (#130320) 2024-11-12 08:29:01 +01:00
LG-ThinQ-Integration
60bf0f6b06
Fix fan's warning TURN_ON, TURN_OFF (#130327)
Co-authored-by: yunseon.park <yunseon.park@lge.com>
2024-11-12 08:26:28 +01:00
G Johansson
3eab72b2aa
Improve exception handling in Nord Pool (#130386)
* Improve exception handling in Nord Pool

* Improve auth string

* Remove auth
2024-11-11 23:02:48 +01:00
Daniel Hjelseth Høyer
d1c3e1caa9
Bump Tibber 0.30.8 (#130388) 2024-11-11 21:05:52 +01:00
Sid
8b547551e2
Bump ruff to 0.7.3 (#130390) 2024-11-11 21:05:41 +01:00
epenet
f1ce7ee8ce
Adjust logging for OptionsFlow deprecation (#130360) 2024-11-11 21:02:09 +01:00
J. Nick Koston
e388e9f396
Fix missing title placeholders in powerwall reauth (#130389) 2024-11-11 20:48:49 +01:00
Markus Lanthaler
96c12fdd10
Update tuya-device-sharing-sdk to version 0.2.1 (#130333) 2024-11-11 20:40:37 +01:00
Noah Husby
e97a5f927c
Bump aiorussound to 4.1.0 (#130382) 2024-11-11 20:26:45 +01:00
epenet
313309a7e0
Remove deprecated YAML loaders (#130364) 2024-11-11 20:24:51 +01:00
Barry vd. Heuvel
ebe62501d6
Bump Weheat wh-python to 2024.11.02 (#130337) 2024-11-11 20:14:12 +01:00
Robert Resch
c54369fe93
Add go2rtc to devcontainer (#130380) 2024-11-11 20:13:20 +01:00
Marc Mueller
c89bf6a9aa
Update pillow to 11.0.0 (#130194) 2024-11-11 20:12:32 +01:00
epenet
906bdda6fa
Use report_usage in integrations (#130366) 2024-11-11 20:09:26 +01:00
Andre Lengwenus
f3708549f0
Code cleanup for LCN integration (#130385) 2024-11-11 20:08:38 +01:00
Andre Lengwenus
3f34ddd74f
Bump lcn-frontend to 0.2.2 (#130383) 2024-11-11 20:07:12 +01:00
Marc Mueller
b19c44b4a5
Update pydantic to 1.10.19 (#130373) 2024-11-11 12:01:47 -06:00
Erik Montnemery
0cc50bc7bc
Fix copy-paste error in STATISTIC_UNIT_TO_UNIT_CONVERTER (#130375) 2024-11-11 11:09:06 -06:00
Joost Lekkerkerker
e56dec2c8e
Bump spotifyaio to 0.8.8 (#130372) 2024-11-11 17:35:54 +01:00
Olivier Corradi
e797149a16
Rename "CO2 Signal" display name to Electricity Maps for consistency (#130242)
* Update strings.json for Electricity Maps

* Update strings.json

* Update config_flow.py

* Update test_config_flow.py

* Fix test
2024-11-11 17:34:29 +01:00
Simon Lamon
c96f1c87a6
Bump python-linkplay to 0.0.20 (#130348) 2024-11-11 17:30:27 +01:00
Erik Elkins
388c5807ea
Add Switchbot Hub 2, Switchbot Meter Pro and Switchbot Meter Pro (CO2) devices to Switchbot Cloud integration. (#130295) 2024-11-11 16:10:52 +01:00
Robert Resch
41c6eeedca
Bump deebot-client to 8.4.1 (#130357) 2024-11-11 15:41:18 +01:00
Lennard Beers
829632b0af
Add binary sensor platform to eq3btsmart (#130352) 2024-11-11 14:27:52 +01:00
Erik Montnemery
5293fc73d8
Sort some code in cloud preferences (#130345)
Sort some code in cloud prefs
2024-11-11 13:21:16 +01:00
Simon Lamon
870bf388e0
Add seek support to LinkPlay (#130349) 2024-11-11 12:49:56 +01:00
Simon Lamon
7a4dac1eb1
Add Spotify and Tidal to playingmode mapping (#130351) 2024-11-11 12:46:02 +01:00
Erik Montnemery
88480d154a
Fix typo in BaseBackupManager.async_restore_backup (#130329) 2024-11-11 12:10:49 +01:00
Lennard Beers
5497c440d9
Prepare eq3btsmart base entity for additional platforms (#130340) 2024-11-11 11:46:11 +01:00
Lennard Beers
1e26cf13d6
Use runtime data for eq3btsmart (#130334) 2024-11-11 10:59:50 +01:00
Nerdix
0dd208a4b9
Add alarm count sensor for Kostal Inverters (#130324) 2024-11-11 09:07:47 +01:00
dependabot[bot]
c3492bc0ed
Bump github/codeql-action from 3.27.0 to 3.27.1 (#130323) 2024-11-11 08:14:42 +01:00
G Johansson
85bf8d1374
Fix Homekit error handling alarm state unknown or unavailable (#130311) 2024-11-10 22:40:23 +00:00
Jan Bouwhuis
e040eb0ff2
Remove extra state attributes from some QNAP sensors (#130310) 2024-11-10 22:26:00 +01:00
Max Shcherbina
d7f41ff8a9
Update generic thermostat strings for clarity and accuracy (#130243) 2024-11-10 22:13:38 +01:00
Jan Bouwhuis
de5437f61e
Remove YAML warning for thethingsnetwork after warning for 6 months (#130307) 2024-11-10 22:12:31 +01:00
Jan Bouwhuis
c52a893e21
Remove YAML import from lcl integration after 6 months deprecation (#130305) 2024-11-10 21:10:18 +01:00
Joost Lekkerkerker
f7f1830b7e
Add support for binary sensor states in Google Assistant (#127652) 2024-11-10 20:34:24 +01:00
Simon Lamon
784ad20fb6
Add diagnostics to LinkPlay (#126768) 2024-11-10 20:31:40 +01:00
Richard Cox
0468e7e7a3
Update Sonarr config flow to standardize ports (#127625)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2024-11-10 20:23:23 +01:00
dotvav
88c227681d
Bump pypalazzetti to 0.1.11 (#130293) 2024-11-10 20:13:31 +01:00
Lennard Beers
3a37ff13a6
Bump eq3btsmart to 1.2.1 (#130297) 2024-11-10 20:12:46 +01:00
Simone Chemelli
73929e6791
Avoid Shelly data update during shutdown (#130301) 2024-11-10 20:11:42 +01:00
Manu
980b0fa5e6
Deprecate api_call action in Habitica integration (#128119) 2024-11-10 19:37:41 +01:00
Tsvi Mostovicz
fbc4a87166
Remove Jewish Calendar config flow upgrade (#129612) 2024-11-10 19:35:01 +01:00
Allen Porter
7f9ec2a79e
Ignore WebRTC candidates for nest cameras (#130294) 2024-11-10 19:27:40 +01:00
Jan Bouwhuis
d8b55d39e4
Remove tibber legacy notify service after 6 months of deprecation (#130292) 2024-11-10 19:27:11 +01:00
Jan Bouwhuis
ee41725b53
Remove jewish_calendar yaml support after 6 months of deprecation (#130291) 2024-11-10 16:51:08 +01:00
J. Diego Rodríguez Royo
ae1203336d
Add links to deprecation issue message for Home Connect Binary door (#129779) 2024-11-10 16:37:53 +01:00
Michael
f10063c9be
Fix translation key for done response in conversation (#130247) 2024-11-10 16:28:58 +01:00
Åke Strandberg
1da4579a09
Add more f-series models to myuplink (#130283) 2024-11-10 15:46:50 +01:00
Jan Bouwhuis
7fd9339ad8
Remove unused file CONFIG_SCHEMA (#130287) 2024-11-10 15:34:08 +01:00
Jan Bouwhuis
de391fa98b
Remove geniushub yaml support after 6 months of deprecation (#130285)
* Remove geniushub YAML import after 6 moths of deprecation

* Update homeassistant/components/geniushub/__init__.py

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

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-11-10 14:58:44 +01:00
J. Nick Koston
70211ab78e
Bump aiohttp to 3.11.0rc0 (#130284) 2024-11-10 13:45:46 +00:00
Nicholas Romyn
a1a08f7755
Ecobee aux cutover threshold (#129474)
* removing extra blank space

* Adding EcobeeAuxCutoverThreshold

First pass.

* minor reorg and changes; testing local check-in

* Adding entity, setting device class and name

* Bumping max value slightly to hopefully accomodate celsius, setting numberMode=box

* fixing the entity name for aux cutover threshold

* Combined async_add_entities

* Using a list comprehension

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

* fixing stuff with listcomprehension

* exchanging call to list.append() to extend with list comprehension

* Updating the class name and the entity name to match the device UI.
Removing abbreviations from entity names

* Fixing tests to match new entity names

* respecting 88 column limit

* Formatting

* Adding test coverage for update/set compressorMinTemp values

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-11-10 14:13:01 +01:00
G Johansson
433321136d
Remove incorrect mark fixture in nordpool (#130278) 2024-11-10 12:28:18 +01:00
Manu
0677bba5bd
Add actions for scoring habits and rewards in Habitica (#129605) 2024-11-10 12:26:07 +01:00
G Johansson
d0ad834d93
Move manual trigger entity tests (#130134) 2024-11-10 12:14:13 +01:00
Simon Lamon
7d2d6a82b0
Allow dynamic max preset in linkplay play preset (#130160) 2024-11-10 12:02:55 +01:00
Allen Porter
e8dc62411a
Improve nest camera stream expiration to be defensive against errors (#130265) 2024-11-10 12:01:59 +01:00
G Johansson
7925007ab4
Bump psutil to 6.1.0 (#130254) 2024-11-10 12:00:45 +01:00
dotvav
7515deddab
Palazzetti DHCP Discovery (#129731)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2024-11-10 11:48:52 +01:00
Marc Mueller
e382f924e6
Add support for Python 3.13 (#129442) 2024-11-10 11:38:56 +01:00
Max Shcherbina
7fdcb98518
Update description for generic hygrostat description (#130244) 2024-11-10 11:25:32 +01:00
Noah Husby
d0dbca41f7
Support additional media player states for Russound RIO (#130261) 2024-11-10 11:20:55 +01:00
G Johansson
f3229c723c
Bump pynordpool to 0.2.2 (#130257) 2024-11-10 11:19:10 +01:00
J. Nick Koston
cafa598fd6
Bump aiohttp to 3.11.0b5 (#130264) 2024-11-10 11:18:12 +01:00
Allen Porter
73a62a09b0
Update nest tests to unload config entries to perform clean teardown (#130266) 2024-11-10 09:54:52 +01:00
Lothar Bach
ecd8dde347
Fix path to tesla fleet key file in config folder (#130124)
* Tesla Fleet load key file from config folder

* Fix test

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2024-11-09 23:21:29 +01:00
Marc Mueller
31a2bb1b98
Fix flaky modbus tests (#130252) 2024-11-09 22:58:16 +01:00
Max Shcherbina
0fc019305e
Fix typo in reminder date language string in Todoist integration (#130241) 2024-11-09 21:38:29 +01:00
Marc Mueller
adb1c59859
Update grpcio to 1.67.1 (#130240) 2024-11-09 21:37:56 +01:00
Manu
5d0277a0d1
Add actions for quest handling to Habitica (#129650) 2024-11-09 19:34:25 +01:00
Allen Porter
21d81d5a5c
Bump google-nest-sdm to 6.1.5 (#130229) 2024-11-09 19:02:15 +01:00
DeerMaximum
0de4bfcc2c
Add missing translation string for NINA (#129826) 2024-11-09 18:33:28 +01:00
jjlawren
2cc5486794
Bump SoCo to 0.30.6 (#130223) 2024-11-09 17:14:40 +01:00
Noah Husby
e3315383ab
Improve entity test coverage for Russound RIO (#129828) 2024-11-09 17:13:57 +01:00
Markus Jacobsen
31b505828b
Simplify Bang & Olufsen source determination (#130072) 2024-11-09 17:13:07 +01:00
Daniel Oltmanns
b61580a937
Add fan preset mode icons and strings to vesync (#129584) 2024-11-09 16:48:00 +01:00
Markus Jacobsen
928e5348e4
Add custom integration action sections support to hassfest (#130148) 2024-11-09 16:47:02 +01:00
Josef Zweck
622682eb43
Change update after button press for lamarzocco (#129616) 2024-11-09 16:42:10 +01:00
Simon Lamon
97fa568876
No longer thrown an error when device is offline in linkplay (#130161) 2024-11-09 16:11:34 +01:00
Manu
c10f078f2a
Add sensors for attribute points (str, int, per, con) to Habitica (#130186) 2024-11-09 16:04:10 +01:00
Simone Chemelli
e6d16f06fc
Fix uptime sensor for Vodafone Station (#130215) 2024-11-09 15:55:39 +01:00
Daniel Hjelseth Høyer
c89ab7a142
Bump pyTibber (#130216) 2024-11-09 15:54:58 +01:00
Jan Bouwhuis
6837ea947c
Cleanup yaml import and legacy file notify service (#130219) 2024-11-09 15:54:18 +01:00
Marco
5f0f29704b
Add smarty reset filters timer button (#129637) 2024-11-09 13:32:00 +01:00
Manu
1f43dc6676
Fix cast skill test in Habitica (#130213) 2024-11-09 13:12:04 +01:00
Marc Mueller
4d7405de2c
Install zlib-dev for pillow wheel build (#130211) 2024-11-09 13:03:26 +01:00
Max Shcherbina
4adffdd1a6
Fix wording in Google Calendar create_event strings for consistency (#130183) 2024-11-09 13:01:59 +01:00
Manu
4e2f5bdb7d
Add tests for cast skill action in Habitica (#129596) 2024-11-09 12:45:50 +01:00
starkillerOG
03bc711c51
Add Reolink chime vehicle tone (#129835) 2024-11-09 12:25:06 +01:00
Marc Mueller
8b8e949bdf
Update wheel builder to 2024.11.0 (#130209) 2024-11-09 12:07:20 +01:00
Erik Montnemery
69ba0d3a50
Report update_percentage in ezviz update entity (#129377) 2024-11-09 11:35:18 +01:00
epenet
25fb70f281
Add blood glucose concentration device class (#129340) 2024-11-09 11:29:24 +01:00
Tom Gamull
0304588bb8
Fix missing unit of measurement for blink wifi strength (#128409) 2024-11-09 11:19:36 +01:00
Josef Zweck
08f5081197
Rename lamarzocco library (#130204) 2024-11-09 11:03:48 +01:00
jb101010-2
701f35488c
Add water price sensor to suez water (#130141)
* Suez water: add water price sensor

* sensor description

* clean up
2024-11-09 10:57:22 +01:00
G Johansson
d11012b2b7
Move check thresholds valid to platform schema in threshold (#129540) 2024-11-09 10:50:11 +01:00
Josef Zweck
8384100e1b
Rename tedee library (#130203) 2024-11-09 10:46:38 +01:00
Tristan Bastian
cd0349ee4d
Bump tplink-omada-client to 1.4.3 (#130184) 2024-11-09 10:41:08 +01:00
Marc Mueller
b413e481cb
Update numpy to 2.1.3 (#130191) 2024-11-09 10:12:52 +01:00
Diogo Gomes
9f7e6048f8
Code quality improvements on utility_meter (#129918)
* clean

* update snapshot

* move name, native_value and native_unit_of_measurement to _attr's

* Apply suggestions from code review

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

---------

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2024-11-09 00:17:43 +01:00
IceBotYT
2802b77f21
Bump nice-go to 0.3.10 (#130173)
Bump Nice G.O. to 0.3.10
2024-11-09 00:12:14 +01:00
J. Nick Koston
964ad43a27
Bump orjson to 3.10.11 (#130182) 2024-11-09 00:07:05 +01:00
TheJulianJES
182be6e0ea
Fix failing UniFi Protect tests on some systems (#129516) 2024-11-08 23:10:29 +01:00
Jakob Schlyter
cd11f01ace
Add support for MW/GW/TW and GWh/TWh (#130089) 2024-11-08 22:12:16 +01:00
G Johansson
742eca5927
Use TemplateStateFromEntityId in Template trigger entity (#130136) 2024-11-08 22:09:43 +01:00
murfy76
48e7fed901
Add voc and formaldehyde to Tuya CO2 Detector (#130119) 2024-11-08 22:03:01 +01:00
Marc Mueller
0a4c0fe7cc
Add option to specify additional markers for wheel build requirements (#129949) 2024-11-08 21:09:53 +01:00
Jan Bouwhuis
9037cb8a7d
Fix typo in go2rtc (#130165)
Fix typo in original
2024-11-08 20:38:38 +01:00
Jan Bouwhuis
c97cc34879
Use f-strings in go2rtc code and test and do not use abbreviation (#130158) 2024-11-08 20:16:46 +01:00
Sheldon Ip
1ac9217630
Fix translations in ollama (#130164) 2024-11-08 20:15:17 +01:00
Simon Lamon
e4036a2f14
Bump python-linkplay to v0.0.18 (#130159) 2024-11-08 20:14:33 +01:00
G Johansson
da9c73a767
Add reconfigure flow to Nord Pool (#130151) 2024-11-08 19:53:52 +01:00
Diogo Gomes
e4aaaf10c3
Fix utility_meter on DST changes (#129862)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2024-11-08 18:44:15 +01:00
Louis Christ
a7be76ba0a
Fix volume_up not working in some cases in bluesound integration (#130146) 2024-11-08 18:40:43 +01:00
Allen Porter
f7cc91903c
Fix bugs in nest stream expiration handling (#130150) 2024-11-08 18:37:00 +01:00
Jan Bouwhuis
4a8a674bd3
Refrase imap fetch service description string (#130152) 2024-11-08 18:36:19 +01:00
Robert Resch
a8db25fbd8
Split test doesn't need to be executed per Python version (#130147) 2024-11-08 18:05:05 +01:00
Klaas Schoute
2dc81ed866
Force int value on port in P1Monitor (#130084) 2024-11-08 16:15:57 +01:00
Shai Ungar
c4762f3ff4
Fix issue when timestamp is None (#130133)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2024-11-08 16:15:28 +01:00
Martin Hjelmare
14285973b8
Bump ha-ffmpeg to 3.2.2 (#130142) 2024-11-08 16:00:24 +01:00
epenet
353ccf3ea7
Only apply OptionsFlowWithConfigEntry deprecation to core (#130054)
* Only apply OptionsFlowWithConfigEntry deprecation to core

* Fix match string in pytest.raises

* Improve coverage
2024-11-08 15:55:19 +01:00
Lektri.co
6b90d8ff1a
Add binary sensor platform to the Lektrico integration (#129872) 2024-11-08 15:54:46 +01:00
Robert Resch
51e691f832
Add go2rtc workaround for HA managed one until upstream fixes it (#130139) 2024-11-08 15:54:14 +01:00
Joost Lekkerkerker
6c7ac7a6ef
Bump spotifyaio to 0.8.7 (#130140) 2024-11-08 15:53:26 +01:00
Bram Kragten
52ed1bf44a
Update frontend to 20241106.2 (#130128) 2024-11-08 15:13:05 +01:00
Petar Petrov
3eab0b704e
Get/Set custom config parameter for zwave_js node (#129332)
* Get/Set custom config parameter for zwave_js node

* add tests

* handle errors on set

* test FailedCommand
2024-11-08 15:12:18 +01:00
G Johansson
1f32e02ba2
Add Nord Pool integration (#129983) 2024-11-08 15:10:51 +01:00
epenet
074418f8f7
Drop OptionsFlowWithConfigEntry usage in homeassistant_hardware (#130078)
* Drop OptionsFlowWithConfigEntry usage in homeassistant_hardware

* Add homeassistant_hardware as other components rely on it

* Maybe core_files not needed after all
2024-11-08 14:53:46 +01:00
Martin Hjelmare
b711b17193
Remove Z-Wave incorrect lock service descriptions (#130034) 2024-11-08 14:50:41 +01:00
Steven B.
03c3d09583
Enable overriding connection port for tplink devices (#129619)
Enable setting a port override during manual config entry setup.

The feature will be undocumented as it's quite a specialized use case generally used for testing purposes.
2024-11-08 14:41:00 +01:00
Robert Resch
f49547d598
Bump uv to 0.5.0 (#130127) 2024-11-08 14:19:46 +01:00
jb101010-2
7678be8e2b
Suez water: simplify config flow (#130083)
Simplify config flow for suez water. Counter_id can now be automatically be fetched by the integration.
The value is provided only in the source code of suez website and therefore not easily accessible to user not familiar with devlopment.
Still possible to explicitly set the value for user with multiple value or value defined elsewhere.
2024-11-08 14:01:36 +01:00
epenet
7672215095
Trigger full CI run on homeassistant_hardware integration changes (#130129)
Add components/homeassistant_hardware to core files
2024-11-08 13:46:40 +01:00
epenet
18cf96b92b
Bring emoncms coverage to 100% (#130092)
Remove mock_setup_entry from emoncms OptionsFlow test
2024-11-08 13:42:19 +01:00
epenet
94d597fd41
Add checks for flow title/description placeholders (#129140)
* Add checks for title placeholders

* Check both title and description

* Improve comment
2024-11-08 13:33:19 +01:00
Alexandre CUER
24b47b50ea
Migrate from entry unique id to emoncms unique id (#129133)
* Migrate from entry unique id to emoncms unique id

* Use a placeholder for the documentation URL

* Use async_set_unique_id in config_flow

* use _abort_if_unique_id_configured in config_flow

* Avoid single-use variable

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

* Add async_migrate_entry

* Remove commented code

* Downgrade version if user add server without uuid

* Improve code quality

* Move code migrating HA to emoncms uuid to init

* Fit doc url in less than 88 chars

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

* Improve code quality

* Only update unique_id with async_update_entry

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

* Make emoncms_client compulsory to get_feed_list

* Improve readability with unique id functions

* Rmv test to give more sense to _migrate_unique_id

---------

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2024-11-08 13:29:10 +01:00
Markus Jacobsen
e3dfa84d65
Bang & Olufsen add beolink grouping (#113438)
* Add Beolink custom services
Add support for media player grouping via beolink
Give media player entity name

* Fix progress not being set to None as Beolink listener
Revert naming changes

* Update API
simplify Beolink attributes

* Improve beolink custom services

* Fix Beolink expandable source check
Add unexpand return value
Set entity name on initialization

* Handle entity naming as intended

* Fix "null" Beolink self friendly name

* Add regex service input validation
Add all_discovered to beolink_expand service
Improve beolink_expand response

* Add service icons

* Fix merge
Remove unnecessary assignment

* Remove invalid typing
Update response typing for updated API

* Revert to old typed response dict method
Remove mypy ignore line
Fix jid possibly used before assignment

* Re add debugging logging

* Fix coroutine
Fix formatting

* Remove unnecessary update control

* Make tests pass
Fix remote leader media position bug
Improve remote leader BangOlufsenSource comparison

* Fix naming and add callback decorators

* Move regex service check to variable
Suppress KeyError
Update tests

* Re-add hass running check

* Improve comments, naming and type hinting

* Remove old temporary fix

* Convert logged warning to raised exception for invalid media_player
Simplify code using walrus operator

* Fix test for invalid media_player grouping

* Improve method naming

* Improve _beolink_sources explanation

* Improve _beolink_sources explanation

* Fix tests

* Remove service responses
Fix and add tests

* Change service to action where applicable

* Show playback progress for listeners

* Fix testing

* Remove useless initialization

* Fix allstandby name

* Fix various casts with assertions
Fix comment placement
Fix group leader group_members rebase error
Replace entity_id method call with attribute

* Add syrupy snapshots for Beolink tests, checking entity states
Use test JIDs 3 and 4 instead of 2 and 3 to avoid invalid attributes in testing

* Add sections for fields using Beolink JIDs directly

* Fix typo

* FIx rebase mistake

* Sort actions alphabetically
2024-11-08 12:06:29 +01:00
nasWebio
ed1366f463
Add NASweb integration (#98118)
* Add NASweb integration

* Fix DeviceInfo import

* Remove commented out code

* Change class name for uniquness

* Drop CoordinatorEntity inheritance

* Rename class Output to more descriptive: RelaySwitch

* Update required webio-api version

* Implement on-the-fly addition/removal of entities

* Set coordinator name matching device name

* Set entities with too old status as unavailable

* Drop Optional in favor of modern typing

* Fix spelling of a variable

* Rename commons to more fitting name: helper

* Remove redundant code

* Let unload fail when there is no coordinator

* Fix bad docstring

* Rename cord to coordinator for clarity

* Remove default value for pop and let it raise exception

* Drop workaround and use get_url from helper.network

* Use webhook to send data from device

* Deinitialize coordinator when no longer needed

* Use Python formattable string

* Use dataclass to store integration data in hass.data

* Raise ConfigEntryNotReady when appropriate

* Refactor NASwebData class

* Move RelaySwitch to switch.py

* Fix ConfigFlow tests

* Create issues when entry fails to load

* Respond when correctly received status update

* Depend on webhook instead of http

* Create issue when status is not received during entry set up

* Make issue_id unique across integration entries

* Remove unnecessary initializations

* Inherit CoordinatorEntity to avoid code duplication

* Optimize property access via assignment in __init__

* Use preexisting mechanism to fill schema with user input

* Fix translation strings

* Handle unavailable or unreachable internal url

* Implement custom coordinator for push driven data updates

* Move module-specific constants to respective modules

* Fix requirements_all.txt

* Fix CODEOWNERS file

* Raise ConfigEntryError instead of issue creation

* Fix entity registry import

* Use HassKey as key in hass.data

* Use typed ConfigEntry

* Store runtime data in config entry

* Rewrite to be more Pythonic

* Move add/remove of switch entities to switch.py

* Skip unnecessary check

* Remove unnecessary type hints

* Remove unnecessary nonlocal

* Use a more descriptive docstring

* Add docstrings to NASwebCoordinator

* Fix formatting

* Use correct return type

* Fix tests to align with changed code

* Remove commented code

* Use serial number as config entry id

* Catch AbortFlow exception

* Update tests to check ConfigEntry Unique ID

* Remove unnecessary form abort
2024-11-08 12:03:32 +01:00
Josef Zweck
5d5908a03f
Add missing string to tedee plus test (#130081) 2024-11-08 08:47:28 +01:00
Kelvin Dekker
3062bad19e
Fix typo in insteon strings (#130085) 2024-11-08 08:47:02 +01:00
Bram Kragten
28832cbd3e
Update frontend to 20241106.1 (#130086) 2024-11-08 08:46:48 +01:00
478 changed files with 16106 additions and 3585 deletions

View file

@ -79,6 +79,7 @@ components: &components
- homeassistant/components/group/**
- homeassistant/components/hassio/**
- homeassistant/components/homeassistant/**
- homeassistant/components/homeassistant_hardware/**
- homeassistant/components/http/**
- homeassistant/components/image/**
- homeassistant/components/input_boolean/**

View file

@ -10,7 +10,7 @@ on:
env:
BUILD_TYPE: core
DEFAULT_PYTHON: "3.12"
DEFAULT_PYTHON: "3.13"
PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"

View file

@ -42,7 +42,7 @@ env:
MYPY_CACHE_VERSION: 9
HA_SHORT_VERSION: "2024.12"
DEFAULT_PYTHON: "3.12"
ALL_PYTHON_VERSIONS: "['3.12']"
ALL_PYTHON_VERSIONS: "['3.12', '3.13']"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
# 10.6 is the current long-term-support
@ -819,10 +819,6 @@ jobs:
needs:
- info
- base
strategy:
fail-fast: false
matrix:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
name: Split tests for full run
steps:
- name: Install additional OS dependencies
@ -836,11 +832,11 @@ jobs:
libgammu-dev
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ matrix.python-version }}
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
@ -858,7 +854,7 @@ jobs:
- name: Upload pytest_buckets
uses: actions/upload-artifact@v4.4.3
with:
name: pytest_buckets-${{ matrix.python-version }}
name: pytest_buckets
path: pytest_buckets.txt
overwrite: true
@ -923,7 +919,7 @@ jobs:
- name: Download pytest_buckets
uses: actions/download-artifact@v4.1.8
with:
name: pytest_buckets-${{ matrix.python-version }}
name: pytest_buckets
- name: Compile English translations
run: |
. venv/bin/activate

View file

@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.27.0
uses: github/codeql-action/init@v3.27.3
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.27.0
uses: github/codeql-action/analyze@v3.27.3
with:
category: "/language:python"

View file

@ -112,7 +112,7 @@ jobs:
strategy:
fail-fast: false
matrix:
abi: ["cp312"]
abi: ["cp312", "cp313"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
@ -135,14 +135,14 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@2024.07.1
uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "libffi-dev;openssl-dev;yaml-dev;nasm"
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-dev"
skip-binary: aiohttp;multidict;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
@ -156,7 +156,7 @@ jobs:
strategy:
fail-fast: false
matrix:
abi: ["cp312"]
abi: ["cp312", "cp313"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
@ -198,6 +198,7 @@ jobs:
split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt
- name: Create requirements for cython<3
if: matrix.abi == 'cp312'
run: |
# Some dependencies still require 'cython<3'
# and don't yet use isolated build environments.
@ -208,7 +209,8 @@ jobs:
cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt
- name: Build wheels (old cython)
uses: home-assistant/wheels@2024.07.1
uses: home-assistant/wheels@2024.11.0
if: matrix.abi == 'cp312'
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@ -223,43 +225,43 @@ jobs:
pip: "'cython<3'"
- name: Build wheels (part 1)
uses: home-assistant/wheels@2024.07.1
uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
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"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl
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"
requirements: "requirements_all.txtaa"
- name: Build wheels (part 2)
uses: home-assistant/wheels@2024.07.1
uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
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"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl
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"
requirements: "requirements_all.txtab"
- name: Build wheels (part 3)
uses: home-assistant/wheels@2024.07.1
uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
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"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl
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"
requirements: "requirements_all.txtac"

View file

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.2
rev: v0.7.3
hooks:
- id: ruff
args:
@ -90,7 +90,7 @@ repos:
pass_filenames: false
language: script
types: [text]
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$
- id: hassfest-mypy-config
name: hassfest-mypy-config
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config

View file

@ -330,6 +330,7 @@ homeassistant.components.mysensors.*
homeassistant.components.myuplink.*
homeassistant.components.nam.*
homeassistant.components.nanoleaf.*
homeassistant.components.nasweb.*
homeassistant.components.neato.*
homeassistant.components.nest.*
homeassistant.components.netatmo.*
@ -339,6 +340,7 @@ homeassistant.components.nfandroidtv.*
homeassistant.components.nightscout.*
homeassistant.components.nissan_leaf.*
homeassistant.components.no_ip.*
homeassistant.components.nordpool.*
homeassistant.components.notify.*
homeassistant.components.notion.*
homeassistant.components.number.*

View file

@ -40,6 +40,8 @@ build.json @home-assistant/supervisor
# Integrations
/homeassistant/components/abode/ @shred86
/tests/components/abode/ @shred86
/homeassistant/components/acaia/ @zweckj
/tests/components/acaia/ @zweckj
/homeassistant/components/accuweather/ @bieniu
/tests/components/accuweather/ @bieniu
/homeassistant/components/acmeda/ @atmurray
@ -970,6 +972,8 @@ build.json @home-assistant/supervisor
/tests/components/nam/ @bieniu
/homeassistant/components/nanoleaf/ @milanmeu @joostlek
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio
/tests/components/nasweb/ @nasWebio
/homeassistant/components/neato/ @Santobert
/tests/components/neato/ @Santobert
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM
@ -1010,6 +1014,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/noaa_tides/ @jdelaney72
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
/tests/components/nobo_hub/ @echoromeo @oyvindwe
/homeassistant/components/nordpool/ @gjohansson-ST
/tests/components/nordpool/ @gjohansson-ST
/homeassistant/components/notify/ @home-assistant/core
/tests/components/notify/ @home-assistant/core
/homeassistant/components/notify_events/ @matrozov @papajojo
@ -1340,6 +1346,8 @@ build.json @home-assistant/supervisor
/tests/components/siren/ @home-assistant/core @raman325
/homeassistant/components/sisyphus/ @jkeljo
/homeassistant/components/sky_hub/ @rogerselwyn
/homeassistant/components/sky_remote/ @dunnmj @saty9
/tests/components/sky_remote/ @dunnmj @saty9
/homeassistant/components/skybell/ @tkdrob
/tests/components/skybell/ @tkdrob
/homeassistant/components/slack/ @tkdrob @fletcherau
@ -1481,8 +1489,8 @@ build.json @home-assistant/supervisor
/tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/homeassistant/components/template/ @PhracturedBlue @home-assistant/core
/tests/components/template/ @PhracturedBlue @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks

View file

@ -13,7 +13,7 @@ ENV \
ARG QEMU_CPU
# Install uv
RUN pip3 install uv==0.4.28
RUN pip3 install uv==0.5.0
WORKDIR /usr/src
@ -55,7 +55,7 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.6/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version

View file

@ -35,6 +35,9 @@ RUN \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc
# Install uv
RUN pip3 install uv

View file

@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.06.1
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.06.1
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.06.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.06.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.06.1
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.11.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.11.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.11.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.11.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.11.0
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io

View file

@ -515,7 +515,7 @@ async def async_from_config_dict(
issue_registry.async_create_issue(
hass,
core.DOMAIN,
"python_version",
f"python_version_{required_python_version}",
is_fixable=False,
severity=issue_registry.IssueSeverity.WARNING,
breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE,

View file

@ -0,0 +1,5 @@
{
"domain": "sky",
"name": "Sky",
"integrations": ["sky_hub", "sky_remote"]
}

View file

@ -0,0 +1,29 @@
"""Initialize the Acaia component."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AcaiaConfigEntry, AcaiaCoordinator
PLATFORMS = [
Platform.BUTTON,
]
async def async_setup_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
"""Set up acaia as config entry."""
coordinator = AcaiaCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -0,0 +1,61 @@
"""Button entities for Acaia scales."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from aioacaia.acaiascale import AcaiaScale
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import AcaiaConfigEntry
from .entity import AcaiaEntity
@dataclass(kw_only=True, frozen=True)
class AcaiaButtonEntityDescription(ButtonEntityDescription):
"""Description for acaia button entities."""
press_fn: Callable[[AcaiaScale], Coroutine[Any, Any, None]]
BUTTONS: tuple[AcaiaButtonEntityDescription, ...] = (
AcaiaButtonEntityDescription(
key="tare",
translation_key="tare",
press_fn=lambda scale: scale.tare(),
),
AcaiaButtonEntityDescription(
key="reset_timer",
translation_key="reset_timer",
press_fn=lambda scale: scale.reset_timer(),
),
AcaiaButtonEntityDescription(
key="start_stop",
translation_key="start_stop",
press_fn=lambda scale: scale.start_stop_timer(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AcaiaConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up button entities and services."""
coordinator = entry.runtime_data
async_add_entities(AcaiaButton(coordinator, description) for description in BUTTONS)
class AcaiaButton(AcaiaEntity, ButtonEntity):
"""Representation of an Acaia button."""
entity_description: AcaiaButtonEntityDescription
async def async_press(self) -> None:
"""Handle the button press."""
await self.entity_description.press_fn(self._scale)

View file

@ -0,0 +1,149 @@
"""Config flow for Acaia integration."""
import logging
from typing import Any
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice
from aioacaia.helpers import is_new_scale
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_IS_NEW_STYLE_SCALE, DOMAIN
_LOGGER = logging.getLogger(__name__)
class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for acaia."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered: dict[str, Any] = {}
self._discovered_devices: dict[str, str] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
mac = format_mac(user_input[CONF_ADDRESS])
try:
is_new_style_scale = await is_new_scale(mac)
except AcaiaDeviceNotFound:
errors["base"] = "device_not_found"
except AcaiaError:
_LOGGER.exception("Error occurred while connecting to the scale")
errors["base"] = "unknown"
except AcaiaUnknownDevice:
return self.async_abort(reason="unsupported_device")
else:
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
if not errors:
return self.async_create_entry(
title=self._discovered_devices[user_input[CONF_ADDRESS]],
data={
CONF_ADDRESS: mac,
CONF_IS_NEW_STYLE_SCALE: is_new_style_scale,
},
)
for device in async_discovered_service_info(self.hass):
self._discovered_devices[device.address] = device.name
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
options = [
SelectOptionDict(
value=device_mac,
label=f"{device_name} ({device_mac})",
)
for device_mac, device_name in self._discovered_devices.items()
]
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
)
)
}
),
errors=errors,
)
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle a discovered Bluetooth device."""
self._discovered[CONF_ADDRESS] = mac = format_mac(discovery_info.address)
self._discovered[CONF_NAME] = discovery_info.name
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
try:
self._discovered[CONF_IS_NEW_STYLE_SCALE] = await is_new_scale(
discovery_info.address
)
except AcaiaDeviceNotFound:
_LOGGER.debug("Device not found during discovery")
return self.async_abort(reason="device_not_found")
except AcaiaError:
_LOGGER.debug(
"Error occurred while connecting to the scale during discovery",
exc_info=True,
)
return self.async_abort(reason="unknown")
except AcaiaUnknownDevice:
_LOGGER.debug("Unsupported device during discovery")
return self.async_abort(reason="unsupported_device")
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle confirmation of Bluetooth discovery."""
if user_input is not None:
return self.async_create_entry(
title=self._discovered[CONF_NAME],
data={
CONF_ADDRESS: self._discovered[CONF_ADDRESS],
CONF_IS_NEW_STYLE_SCALE: self._discovered[CONF_IS_NEW_STYLE_SCALE],
},
)
self.context["title_placeholders"] = placeholders = {
CONF_NAME: self._discovered[CONF_NAME]
}
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders=placeholders,
)

View file

@ -0,0 +1,4 @@
"""Constants for component."""
DOMAIN = "acaia"
CONF_IS_NEW_STYLE_SCALE = "is_new_style_scale"

View file

@ -0,0 +1,86 @@
"""Coordinator for Acaia integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from aioacaia.acaiascale import AcaiaScale
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_IS_NEW_STYLE_SCALE
SCAN_INTERVAL = timedelta(seconds=15)
_LOGGER = logging.getLogger(__name__)
type AcaiaConfigEntry = ConfigEntry[AcaiaCoordinator]
class AcaiaCoordinator(DataUpdateCoordinator[None]):
"""Class to handle fetching data from the scale."""
config_entry: AcaiaConfigEntry
def __init__(self, hass: HomeAssistant, entry: AcaiaConfigEntry) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name="acaia coordinator",
update_interval=SCAN_INTERVAL,
config_entry=entry,
)
self._scale = AcaiaScale(
address_or_ble_device=entry.data[CONF_ADDRESS],
name=entry.title,
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
notify_callback=self.async_update_listeners,
)
@property
def scale(self) -> AcaiaScale:
"""Return the scale object."""
return self._scale
async def _async_update_data(self) -> None:
"""Fetch data."""
# scale is already connected, return
if self._scale.connected:
return
# scale is not connected, try to connect
try:
await self._scale.connect(setup_tasks=False)
except (AcaiaDeviceNotFound, AcaiaError, TimeoutError) as ex:
_LOGGER.debug(
"Could not connect to scale: %s, Error: %s",
self.config_entry.data[CONF_ADDRESS],
ex,
)
self._scale.device_disconnected_handler(notify=False)
return
# connected, set up background tasks
if not self._scale.heartbeat_task or self._scale.heartbeat_task.done():
self._scale.heartbeat_task = self.config_entry.async_create_background_task(
hass=self.hass,
target=self._scale.send_heartbeats(),
name="acaia_heartbeat_task",
)
if not self._scale.process_queue_task or self._scale.process_queue_task.done():
self._scale.process_queue_task = (
self.config_entry.async_create_background_task(
hass=self.hass,
target=self._scale.process_queue(),
name="acaia_process_queue_task",
)
)

View file

@ -0,0 +1,40 @@
"""Base class for Acaia entities."""
from dataclasses import dataclass
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AcaiaCoordinator
@dataclass
class AcaiaEntity(CoordinatorEntity[AcaiaCoordinator]):
"""Common elements for all entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AcaiaCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._scale = coordinator.scale
self._attr_unique_id = f"{self._scale.mac}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._scale.mac)},
manufacturer="Acaia",
model=self._scale.model,
suggested_area="Kitchen",
)
@property
def available(self) -> bool:
"""Returns whether entity is available."""
return super().available and self._scale.connected

View file

@ -0,0 +1,15 @@
{
"entity": {
"button": {
"tare": {
"default": "mdi:scale-balance"
},
"reset_timer": {
"default": "mdi:timer-refresh"
},
"start_stop": {
"default": "mdi:timer-play"
}
}
}
}

View file

@ -0,0 +1,29 @@
{
"domain": "acaia",
"name": "Acaia",
"bluetooth": [
{
"manufacturer_id": 16962
},
{
"local_name": "ACAIA*"
},
{
"local_name": "PYXIS-*"
},
{
"local_name": "LUNAR-*"
},
{
"local_name": "PROCHBT001"
}
],
"codeowners": ["@zweckj"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/acaia",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioacaia"],
"requirements": ["aioacaia==0.1.6"]
}

View file

@ -0,0 +1,38 @@
{
"config": {
"flow_title": "{name}",
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"unsupported_device": "This device is not supported."
},
"error": {
"device_not_found": "Device could not be found.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
},
"user": {
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]"
}
}
}
},
"entity": {
"button": {
"tare": {
"name": "Tare"
},
"reset_timer": {
"name": "Reset timer"
},
"start_stop": {
"name": "Start/stop timer"
}
}
}
}

View file

@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==0.9.5"]
"requirements": ["aioairzone==0.9.6"]
}

View file

@ -6,7 +6,7 @@ import asyncio
from datetime import timedelta
from functools import partial
import logging
from typing import Any, Final, final
from typing import TYPE_CHECKING, Any, Final, final
from propcache import cached_property
import voluptuous as vol
@ -221,9 +221,15 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
@property
def state(self) -> str | None:
"""Return the current state."""
if (alarm_state := self.alarm_state) is None:
return None
return alarm_state
if (alarm_state := self.alarm_state) is not None:
return alarm_state
if self._attr_state is not None:
# Backwards compatibility for integrations that set state directly
# Should be removed in 2025.11
if TYPE_CHECKING:
assert isinstance(self._attr_state, str)
return self._attr_state
return None
@cached_property
def alarm_state(self) -> AlarmControlPanelState | None:

View file

@ -32,7 +32,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups."""
await backup_manager.async_create_backup()
await backup_manager.async_create_backup(on_progress=None)
if backup_task := backup_manager.backup_task:
await backup_task
hass.services.async_register(DOMAIN, "create", async_handle_create_service)

View file

@ -2,23 +2,26 @@
from __future__ import annotations
import asyncio
from http import HTTPStatus
from typing import cast
from aiohttp import BodyPartReader
from aiohttp.hdrs import CONTENT_DISPOSITION
from aiohttp.web import FileResponse, Request, Response
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify
from .const import DOMAIN
from .manager import BaseBackupManager
from .const import DATA_MANAGER
@callback
def async_register_http_views(hass: HomeAssistant) -> None:
"""Register the http views."""
hass.http.register_view(DownloadBackupView)
hass.http.register_view(UploadBackupView)
class DownloadBackupView(HomeAssistantView):
@ -36,7 +39,7 @@ class DownloadBackupView(HomeAssistantView):
if not request["hass_user"].is_admin:
return Response(status=HTTPStatus.UNAUTHORIZED)
manager: BaseBackupManager = request.app[KEY_HASS].data[DOMAIN]
manager = request.app[KEY_HASS].data[DATA_MANAGER]
backup = await manager.async_get_backup(slug=slug)
if backup is None or not backup.path.exists():
@ -48,3 +51,29 @@ class DownloadBackupView(HomeAssistantView):
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
},
)
class UploadBackupView(HomeAssistantView):
"""Generate backup view."""
url = "/api/backup/upload"
name = "api:backup:upload"
@require_admin
async def post(self, request: Request) -> Response:
"""Upload a backup file."""
manager = request.app[KEY_HASS].data[DATA_MANAGER]
reader = await request.multipart()
contents = cast(BodyPartReader, await reader.next())
try:
await manager.async_receive_backup(contents=contents)
except OSError as err:
return Response(
body=f"Can't write backup file {err}",
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
except asyncio.CancelledError:
return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
return Response(status=HTTPStatus.CREATED)

View file

@ -4,16 +4,21 @@ from __future__ import annotations
import abc
import asyncio
from collections.abc import Callable
from dataclasses import asdict, dataclass
import hashlib
import io
import json
from pathlib import Path
from queue import SimpleQueue
import shutil
import tarfile
from tarfile import TarError
from tempfile import TemporaryDirectory
import time
from typing import Any, Protocol, cast
import aiohttp
from securetar import SecureTarFile, atomic_contents_add
from homeassistant.backup_restore import RESTORE_BACKUP_FILE
@ -30,6 +35,13 @@ from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
BUF_SIZE = 2**20 * 4 # 4MB
@dataclass(slots=True)
class NewBackup:
"""New backup class."""
slug: str
@dataclass(slots=True)
class Backup:
"""Backup class."""
@ -45,6 +57,15 @@ class Backup:
return {**asdict(self), "path": self.path.as_posix()}
@dataclass(slots=True)
class BackupProgress:
"""Backup progress class."""
done: bool
stage: str | None
success: bool | None
class BackupPlatformProtocol(Protocol):
"""Define the format that backup platforms can have."""
@ -61,7 +82,7 @@ class BaseBackupManager(abc.ABC):
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup manager."""
self.hass = hass
self.backing_up = False
self.backup_task: asyncio.Task | None = None
self.backups: dict[str, Backup] = {}
self.loaded_platforms = False
self.platforms: dict[str, BackupPlatformProtocol] = {}
@ -126,10 +147,15 @@ class BaseBackupManager(abc.ABC):
@abc.abstractmethod
async def async_restore_backup(self, slug: str, **kwargs: Any) -> None:
"""Restpre a backup."""
"""Restore a backup."""
@abc.abstractmethod
async def async_create_backup(self, **kwargs: Any) -> Backup:
async def async_create_backup(
self,
*,
on_progress: Callable[[BackupProgress], None] | None,
**kwargs: Any,
) -> NewBackup:
"""Generate a backup."""
@abc.abstractmethod
@ -147,6 +173,15 @@ class BaseBackupManager(abc.ABC):
async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Remove a backup."""
@abc.abstractmethod
async def async_receive_backup(
self,
*,
contents: aiohttp.BodyPartReader,
**kwargs: Any,
) -> None:
"""Receive and store a backup file from upload."""
class BackupManager(BaseBackupManager):
"""Backup manager for the Backup integration."""
@ -222,17 +257,93 @@ class BackupManager(BaseBackupManager):
LOGGER.debug("Removed backup located at %s", backup.path)
self.backups.pop(slug)
async def async_create_backup(self, **kwargs: Any) -> Backup:
"""Generate a backup."""
if self.backing_up:
raise HomeAssistantError("Backup already in progress")
async def async_receive_backup(
self,
*,
contents: aiohttp.BodyPartReader,
**kwargs: Any,
) -> None:
"""Receive and store a backup file from upload."""
queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = (
SimpleQueue()
)
temp_dir_handler = await self.hass.async_add_executor_job(TemporaryDirectory)
target_temp_file = Path(
temp_dir_handler.name, contents.filename or "backup.tar"
)
def _sync_queue_consumer() -> None:
with target_temp_file.open("wb") as file_handle:
while True:
if (_chunk_future := queue.get()) is None:
break
_chunk, _future = _chunk_future
if _future is not None:
self.hass.loop.call_soon_threadsafe(_future.set_result, None)
file_handle.write(_chunk)
fut: asyncio.Future[None] | None = None
try:
fut = self.hass.async_add_executor_job(_sync_queue_consumer)
megabytes_sending = 0
while chunk := await contents.read_chunk(BUF_SIZE):
megabytes_sending += 1
if megabytes_sending % 5 != 0:
queue.put_nowait((chunk, None))
continue
chunk_future = self.hass.loop.create_future()
queue.put_nowait((chunk, chunk_future))
await asyncio.wait(
(fut, chunk_future),
return_when=asyncio.FIRST_COMPLETED,
)
if fut.done():
# The executor job failed
break
queue.put_nowait(None) # terminate queue consumer
finally:
if fut is not None:
await fut
def _move_and_cleanup() -> None:
shutil.move(target_temp_file, self.backup_dir / target_temp_file.name)
temp_dir_handler.cleanup()
await self.hass.async_add_executor_job(_move_and_cleanup)
await self.load_backups()
async def async_create_backup(
self,
*,
on_progress: Callable[[BackupProgress], None] | None,
**kwargs: Any,
) -> NewBackup:
"""Generate a backup."""
if self.backup_task:
raise HomeAssistantError("Backup already in progress")
backup_name = f"Core {HAVERSION}"
date_str = dt_util.now().isoformat()
slug = _generate_slug(date_str, backup_name)
self.backup_task = self.hass.async_create_task(
self._async_create_backup(backup_name, date_str, slug, on_progress),
name="backup_manager_create_backup",
eager_start=False, # To ensure the task is not started before we return
)
return NewBackup(slug=slug)
async def _async_create_backup(
self,
backup_name: str,
date_str: str,
slug: str,
on_progress: Callable[[BackupProgress], None] | None,
) -> Backup:
"""Generate a backup."""
success = False
try:
self.backing_up = True
await self.async_pre_backup_actions()
backup_name = f"Core {HAVERSION}"
date_str = dt_util.now().isoformat()
slug = _generate_slug(date_str, backup_name)
backup_data = {
"slug": slug,
@ -259,9 +370,12 @@ class BackupManager(BaseBackupManager):
if self.loaded_backups:
self.backups[slug] = backup
LOGGER.debug("Generated new backup with slug %s", slug)
success = True
return backup
finally:
self.backing_up = False
if on_progress:
on_progress(BackupProgress(done=True, stage=None, success=success))
self.backup_task = None
await self.async_post_backup_actions()
def _mkdir_and_generate_backup_contents(

View file

@ -8,6 +8,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import DATA_MANAGER, LOGGER
from .manager import BackupProgress
@callback
@ -40,7 +41,7 @@ async def handle_info(
msg["id"],
{
"backups": list(backups.values()),
"backing_up": manager.backing_up,
"backing_up": manager.backup_task is not None,
},
)
@ -113,7 +114,11 @@ async def handle_create(
msg: dict[str, Any],
) -> None:
"""Generate a backup."""
backup = await hass.data[DATA_MANAGER].async_create_backup()
def on_progress(progress: BackupProgress) -> None:
connection.send_message(websocket_api.event_message(msg["id"], progress))
backup = await hass.data[DATA_MANAGER].async_create_backup(on_progress=on_progress)
connection.send_result(msg["id"], backup)
@ -127,7 +132,6 @@ async def handle_backup_start(
) -> None:
"""Backup start notification."""
manager = hass.data[DATA_MANAGER]
manager.backing_up = True
LOGGER.debug("Backup start notification")
try:
@ -149,7 +153,6 @@ async def handle_backup_end(
) -> None:
"""Backup end notification."""
manager = hass.data[DATA_MANAGER]
manager.backing_up = False
LOGGER.debug("Backup end notification")
try:

View file

@ -17,62 +17,9 @@ from homeassistant.components.media_player import (
class BangOlufsenSource:
"""Class used for associating device source ids with friendly names. May not include all sources."""
URI_STREAMER: Final[Source] = Source(
name="Audio Streamer",
id="uriStreamer",
is_seekable=False,
is_enabled=True,
is_playable=True,
)
BLUETOOTH: Final[Source] = Source(
name="Bluetooth",
id="bluetooth",
is_seekable=False,
is_enabled=True,
is_playable=True,
)
CHROMECAST: Final[Source] = Source(
name="Chromecast built-in",
id="chromeCast",
is_seekable=False,
is_enabled=True,
is_playable=True,
)
LINE_IN: Final[Source] = Source(
name="Line-In",
id="lineIn",
is_seekable=False,
is_enabled=True,
is_playable=True,
)
SPDIF: Final[Source] = Source(
name="Optical",
id="spdif",
is_seekable=False,
is_enabled=True,
is_playable=True,
)
NET_RADIO: Final[Source] = Source(
name="B&O Radio",
id="netRadio",
is_seekable=False,
is_enabled=True,
is_playable=True,
)
DEEZER: Final[Source] = Source(
name="Deezer",
id="deezer",
is_seekable=True,
is_enabled=True,
is_playable=True,
)
TIDAL: Final[Source] = Source(
name="Tidal",
id="tidal",
is_seekable=True,
is_enabled=True,
is_playable=True,
)
LINE_IN: Final[Source] = Source(name="Line-In", id="lineIn")
SPDIF: Final[Source] = Source(name="Optical", id="spdif")
URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer")
BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = {

View file

@ -0,0 +1,9 @@
{
"services": {
"beolink_join": { "service": "mdi:location-enter" },
"beolink_expand": { "service": "mdi:location-enter" },
"beolink_unexpand": { "service": "mdi:location-exit" },
"beolink_leave": { "service": "mdi:close-circle-outline" },
"beolink_allstandby": { "service": "mdi:close-circle-multiple-outline" }
}
}

View file

@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, cast
from aiohttp import ClientConnectorError
from mozart_api import __version__ as MOZART_API_VERSION
from mozart_api.exceptions import ApiException
from mozart_api.exceptions import ApiException, NotFoundException
from mozart_api.models import (
Action,
Art,
@ -38,6 +38,7 @@ from mozart_api.models import (
VolumeState,
)
from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@ -55,10 +56,17 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_get_current_platform,
)
from homeassistant.util.dt import utcnow
from . import BangOlufsenConfigEntry
@ -116,6 +124,58 @@ async def async_setup_entry(
]
)
# Register actions.
platform = async_get_current_platform()
jid_regex = vol.Match(
r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$"
)
platform.async_register_entity_service(
name="beolink_join",
schema={vol.Optional("beolink_jid"): jid_regex},
func="async_beolink_join",
)
platform.async_register_entity_service(
name="beolink_expand",
schema={
vol.Exclusive("all_discovered", "devices", ""): cv.boolean,
vol.Exclusive(
"beolink_jids",
"devices",
"Define either specific Beolink JIDs or all discovered",
): vol.All(
cv.ensure_list,
[jid_regex],
),
},
func="async_beolink_expand",
)
platform.async_register_entity_service(
name="beolink_unexpand",
schema={
vol.Required("beolink_jids"): vol.All(
cv.ensure_list,
[jid_regex],
),
},
func="async_beolink_unexpand",
)
platform.async_register_entity_service(
name="beolink_leave",
schema=None,
func="async_beolink_leave",
)
platform.async_register_entity_service(
name="beolink_allstandby",
schema=None,
func="async_beolink_allstandby",
)
class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
"""Representation of a media player."""
@ -156,6 +216,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Beolink compatible sources
self._beolink_sources: dict[str, bool] = {}
self._remote_leader: BeolinkLeader | None = None
# Extra state attributes for showing Beolink: peer(s), listener(s), leader and self
self._beolink_attributes: dict[str, dict[str, dict[str, str]]] = {}
async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers."""
@ -165,6 +227,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
CONNECTION_STATUS: self._async_update_connection_state,
WebsocketNotification.ACTIVE_LISTENING_MODE: self._async_update_sound_modes,
WebsocketNotification.BEOLINK: self._async_update_beolink,
WebsocketNotification.CONFIGURATION: self._async_update_name_and_beolink,
WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error,
WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink,
WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress,
@ -230,6 +293,9 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
await self._async_update_sound_modes()
# Update beolink attributes and device name.
await self._async_update_name_and_beolink()
async def async_update(self) -> None:
"""Update queue settings."""
# The WebSocket event listener is the main handler for connection state.
@ -372,9 +438,44 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self.async_write_ha_state()
async def _async_update_name_and_beolink(self) -> None:
"""Update the device friendly name."""
beolink_self = await self._client.get_beolink_self()
# Update device name
device_registry = dr.async_get(self.hass)
assert self.device_entry is not None
device_registry.async_update_device(
device_id=self.device_entry.id,
name=beolink_self.friendly_name,
)
await self._async_update_beolink()
async def _async_update_beolink(self) -> None:
"""Update the current Beolink leader, listeners, peers and self."""
self._beolink_attributes = {}
assert self.device_entry is not None
assert self.device_entry.name is not None
# Add Beolink self
self._beolink_attributes = {
"beolink": {"self": {self.device_entry.name: self._beolink_jid}}
}
# Add Beolink peers
peers = await self._client.get_beolink_peers()
if len(peers) > 0:
self._beolink_attributes["beolink"]["peers"] = {}
for peer in peers:
self._beolink_attributes["beolink"]["peers"][peer.friendly_name] = (
peer.jid
)
# Add Beolink listeners / leader
self._remote_leader = self._playback_metadata.remote_leader
@ -394,9 +495,14 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Add self
group_members.append(self.entity_id)
self._beolink_attributes["beolink"]["leader"] = {
self._remote_leader.friendly_name: self._remote_leader.jid,
}
# If not listener, check if leader.
else:
beolink_listeners = await self._client.get_beolink_listeners()
beolink_listeners_attribute = {}
# Check if the device is a leader.
if len(beolink_listeners) > 0:
@ -417,6 +523,18 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
for beolink_listener in beolink_listeners
]
)
# Update Beolink attributes
for beolink_listener in beolink_listeners:
for peer in peers:
if peer.jid == beolink_listener.jid:
# Get the friendly names for the listeners from the peers
beolink_listeners_attribute[peer.friendly_name] = (
beolink_listener.jid
)
break
self._beolink_attributes["beolink"]["listeners"] = (
beolink_listeners_attribute
)
self._attr_group_members = group_members
@ -570,38 +688,19 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
@property
def source(self) -> str | None:
"""Return the current audio source."""
# Try to fix some of the source_change chromecast weirdness.
if hasattr(self._playback_metadata, "title"):
# source_change is chromecast but line in is selected.
if self._playback_metadata.title == BangOlufsenSource.LINE_IN.name:
return BangOlufsenSource.LINE_IN.name
# source_change is chromecast but bluetooth is selected.
if self._playback_metadata.title == BangOlufsenSource.BLUETOOTH.name:
return BangOlufsenSource.BLUETOOTH.name
# source_change is line in, bluetooth or optical but stale metadata is sent through the WebSocket,
# And the source has not changed.
if self._source_change.id in (
BangOlufsenSource.BLUETOOTH.id,
BangOlufsenSource.LINE_IN.id,
BangOlufsenSource.SPDIF.id,
):
return BangOlufsenSource.CHROMECAST.name
# source_change is chromecast and there is metadata but no artwork. Bluetooth does support metadata but not artwork
# So i assume that it is bluetooth and not chromecast
if (
hasattr(self._playback_metadata, "art")
and self._playback_metadata.art is not None
and len(self._playback_metadata.art) == 0
and self._source_change.id == BangOlufsenSource.CHROMECAST.id
):
return BangOlufsenSource.BLUETOOTH.name
return self._source_change.name
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return information that is not returned anywhere else."""
attributes: dict[str, Any] = {}
# Add Beolink attributes
if self._beolink_attributes:
attributes.update(self._beolink_attributes)
return attributes
async def async_turn_off(self) -> None:
"""Set the device to "networkStandby"."""
await self._client.post_standby()
@ -873,23 +972,30 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Beolink compatible B&O device.
# Repeated presses / calls will cycle between compatible playing devices.
if len(group_members) == 0:
await self._async_beolink_join()
await self.async_beolink_join()
return
# Get JID for each group member
jids = [self._get_beolink_jid(group_member) for group_member in group_members]
await self._async_beolink_expand(jids)
await self.async_beolink_expand(jids)
async def async_unjoin_player(self) -> None:
"""Unjoin Beolink session. End session if leader."""
await self._async_beolink_leave()
await self.async_beolink_leave()
async def _async_beolink_join(self) -> None:
# Custom actions:
async def async_beolink_join(self, beolink_jid: str | None = None) -> None:
"""Join a Beolink multi-room experience."""
await self._client.join_latest_beolink_experience()
if beolink_jid is None:
await self._client.join_latest_beolink_experience()
else:
await self._client.join_beolink_peer(jid=beolink_jid)
async def _async_beolink_expand(self, beolink_jids: list[str]) -> None:
async def async_beolink_expand(
self, beolink_jids: list[str] | None = None, all_discovered: bool = False
) -> None:
"""Expand a Beolink multi-room experience with a device or devices."""
# Ensure that the current source is expandable
if not self._beolink_sources[cast(str, self._source_change.id)]:
raise ServiceValidationError(
@ -901,10 +1007,37 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
},
)
# Try to expand to all defined devices
for beolink_jid in beolink_jids:
await self._client.post_beolink_expand(jid=beolink_jid)
# Expand to all discovered devices
if all_discovered:
peers = await self._client.get_beolink_peers()
async def _async_beolink_leave(self) -> None:
for peer in peers:
try:
await self._client.post_beolink_expand(jid=peer.jid)
except NotFoundException:
_LOGGER.warning("Unable to expand to %s", peer.jid)
# Try to expand to all defined devices
elif beolink_jids:
for beolink_jid in beolink_jids:
try:
await self._client.post_beolink_expand(jid=beolink_jid)
except NotFoundException:
_LOGGER.warning(
"Unable to expand to %s. Is the device available on the network?",
beolink_jid,
)
async def async_beolink_unexpand(self, beolink_jids: list[str]) -> None:
"""Unexpand a Beolink multi-room experience with a device or devices."""
# Unexpand all defined devices
for beolink_jid in beolink_jids:
await self._client.post_beolink_unexpand(jid=beolink_jid)
async def async_beolink_leave(self) -> None:
"""Leave the current Beolink experience."""
await self._client.post_beolink_leave()
async def async_beolink_allstandby(self) -> None:
"""Set all connected Beolink devices to standby."""
await self._client.post_beolink_allstandby()

View file

@ -0,0 +1,79 @@
beolink_allstandby:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
beolink_expand:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
fields:
all_discovered:
required: false
example: false
selector:
boolean:
jid_options:
collapsed: false
fields:
beolink_jids:
required: false
example: >-
[
1111.2222222.33333333@products.bang-olufsen.com,
4444.5555555.66666666@products.bang-olufsen.com
]
selector:
object:
beolink_join:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
fields:
jid_options:
collapsed: false
fields:
beolink_jid:
required: false
example: 1111.2222222.33333333@products.bang-olufsen.com
selector:
text:
beolink_leave:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
beolink_unexpand:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
fields:
jid_options:
collapsed: false
fields:
beolink_jids:
required: true
example: >-
[
1111.2222222.33333333@products.bang-olufsen.com,
4444.5555555.66666666@products.bang-olufsen.com
]
selector:
object:

View file

@ -1,4 +1,8 @@
{
"common": {
"jid_options_name": "JID options",
"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": {
"api_exception": "[%key:common::config_flow::error::cannot_connect%]",
@ -25,6 +29,68 @@
}
}
},
"services": {
"beolink_allstandby": {
"name": "Beolink all standby",
"description": "Set all Connected Beolink devices to standby."
},
"beolink_expand": {
"name": "Beolink expand",
"description": "Expand current Beolink experience.",
"fields": {
"all_discovered": {
"name": "All discovered",
"description": "Expand Beolink experience to all discovered devices."
},
"beolink_jids": {
"name": "Beolink JIDs",
"description": "Specify which Beolink JIDs will join current Beolink experience."
}
},
"sections": {
"jid_options": {
"name": "[%key:component::bang_olufsen::common::jid_options_name%]",
"description": "[%key:component::bang_olufsen::common::jid_options_description%]"
}
}
},
"beolink_join": {
"name": "Beolink join",
"description": "Join a Beolink experience.",
"fields": {
"beolink_jid": {
"name": "Beolink JID",
"description": "Manually specify Beolink JID to join."
}
},
"sections": {
"jid_options": {
"name": "[%key:component::bang_olufsen::common::jid_options_name%]",
"description": "[%key:component::bang_olufsen::common::jid_options_description%]"
}
}
},
"beolink_leave": {
"name": "Beolink leave",
"description": "Leave a Beolink experience."
},
"beolink_unexpand": {
"name": "Beolink unexpand",
"description": "Unexpand from current Beolink experience.",
"fields": {
"beolink_jids": {
"name": "Beolink JIDs",
"description": "Specify which Beolink JIDs will leave from current Beolink experience."
}
},
"sections": {
"jid_options": {
"name": "[%key:component::bang_olufsen::common::jid_options_name%]",
"description": "[%key:component::bang_olufsen::common::jid_options_description%]"
}
}
}
},
"exceptions": {
"m3u_invalid_format": {
"message": "Media sources with the .m3u extension are not supported."

View file

@ -120,6 +120,11 @@ class BangOlufsenWebsocket(BangOlufsenBase):
self.hass,
f"{self._unique_id}_{WebsocketNotification.BEOLINK}",
)
elif notification_type is WebsocketNotification.CONFIGURATION:
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.CONFIGURATION}",
)
elif notification_type is WebsocketNotification.REMOTE_MENU_CHANGED:
async_dispatcher_send(
self.hass,

View file

@ -10,7 +10,11 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -32,6 +36,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=TYPE_WIFI_STRENGTH,
translation_key="wifi_strength",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),

View file

@ -770,7 +770,7 @@ class BluesoundPlayer(MediaPlayerEntity):
async def async_set_volume_level(self, volume: float) -> None:
"""Send volume_up command to media player."""
volume = int(volume * 100)
volume = int(round(volume * 100))
volume = min(100, volume)
volume = max(0, volume)

View file

@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
"requirements": ["aiostreammagic==2.8.4"],
"requirements": ["aiostreammagic==2.8.5"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}

View file

@ -51,8 +51,13 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
CambridgeAudioSelectEntityDescription(
key="display_brightness",
translation_key="display_brightness",
options=[x.value for x in DisplayBrightness],
options=[
DisplayBrightness.BRIGHT.value,
DisplayBrightness.DIM.value,
DisplayBrightness.OFF.value,
],
entity_category=EntityCategory.CONFIG,
load_fn=lambda client: client.display.brightness != DisplayBrightness.NONE,
value_fn=lambda client: client.display.brightness,
set_value_fn=lambda client, value: client.set_display_brightness(
DisplayBrightness(value)

View file

@ -6,7 +6,7 @@ from abc import ABC, abstractmethod
import asyncio
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import asdict, dataclass, field
from functools import cache, partial
from functools import cache, partial, wraps
import logging
from typing import TYPE_CHECKING, Any, Protocol
@ -205,6 +205,49 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
)
type WsCommandWithCamera = Callable[
[websocket_api.ActiveConnection, dict[str, Any], Camera],
Awaitable[None],
]
def require_webrtc_support(
error_code: str,
) -> Callable[[WsCommandWithCamera], websocket_api.AsyncWebSocketCommandHandler]:
"""Validate that the camera supports WebRTC."""
def decorate(
func: WsCommandWithCamera,
) -> websocket_api.AsyncWebSocketCommandHandler:
"""Decorate func."""
@wraps(func)
async def validate(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Validate that the camera supports WebRTC."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
error_code,
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
await func(connection, msg, camera)
return validate
return decorate
@websocket_api.websocket_command(
{
vol.Required("type"): "camera/webrtc/offer",
@ -213,8 +256,9 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
}
)
@websocket_api.async_response
@require_webrtc_support("webrtc_offer_failed")
async def ws_webrtc_offer(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None:
"""Handle the signal path for a WebRTC stream.
@ -226,20 +270,7 @@ async def ws_webrtc_offer(
Async friendly.
"""
entity_id = msg["entity_id"]
offer = msg["offer"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_offer_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
session_id = ulid()
connection.subscriptions[msg["id"]] = partial(
camera.close_webrtc_session, session_id
@ -278,23 +309,11 @@ async def ws_webrtc_offer(
}
)
@websocket_api.async_response
@require_webrtc_support("webrtc_get_client_config_failed")
async def ws_get_client_config(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None:
"""Handle get WebRTC client config websocket command."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_get_client_config_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
config = camera.async_get_webrtc_client_configuration().to_frontend_dict()
connection.send_result(
msg["id"],
@ -311,23 +330,11 @@ async def ws_get_client_config(
}
)
@websocket_api.async_response
@require_webrtc_support("webrtc_candidate_failed")
async def ws_candidate(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None:
"""Handle WebRTC candidate websocket command."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_candidate_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
await camera.async_on_webrtc_candidate(
msg["session_id"], RTCIceCandidate(msg["candidate"])
)

View file

@ -440,16 +440,16 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]:
@websocket_api.websocket_command(
{
vol.Required("type"): "cloud/update_prefs",
vol.Optional(PREF_ENABLE_GOOGLE): bool,
vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool,
vol.Optional(PREF_ENABLE_GOOGLE): bool,
vol.Optional(PREF_GOOGLE_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All(
vol.Coerce(tuple), validate_language_voice
),
vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool,
}
)
@websocket_api.async_response

View file

@ -163,21 +163,21 @@ class CloudPreferences:
async def async_update(
self,
*,
google_enabled: bool | UndefinedType = UNDEFINED,
alexa_enabled: bool | UndefinedType = UNDEFINED,
remote_enabled: bool | UndefinedType = UNDEFINED,
google_secure_devices_pin: str | None | UndefinedType = UNDEFINED,
cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED,
cloud_user: str | UndefinedType = UNDEFINED,
alexa_report_state: bool | UndefinedType = UNDEFINED,
google_report_state: bool | UndefinedType = UNDEFINED,
tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED,
remote_domain: str | None | UndefinedType = UNDEFINED,
alexa_settings_version: int | UndefinedType = UNDEFINED,
google_settings_version: int | UndefinedType = UNDEFINED,
google_connected: bool | UndefinedType = UNDEFINED,
remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
cloud_ice_servers_enabled: bool | UndefinedType = UNDEFINED,
cloud_user: str | UndefinedType = UNDEFINED,
cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED,
google_connected: bool | UndefinedType = UNDEFINED,
google_enabled: bool | UndefinedType = UNDEFINED,
google_report_state: bool | UndefinedType = UNDEFINED,
google_secure_devices_pin: str | None | UndefinedType = UNDEFINED,
google_settings_version: int | UndefinedType = UNDEFINED,
remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
remote_domain: str | None | UndefinedType = UNDEFINED,
remote_enabled: bool | UndefinedType = UNDEFINED,
tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED,
) -> None:
"""Update user preferences."""
prefs = {**self._prefs}
@ -186,21 +186,21 @@ class CloudPreferences:
{
key: value
for key, value in (
(PREF_ENABLE_GOOGLE, google_enabled),
(PREF_ENABLE_ALEXA, alexa_enabled),
(PREF_ENABLE_REMOTE, remote_enabled),
(PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
(PREF_CLOUDHOOKS, cloudhooks),
(PREF_CLOUD_USER, cloud_user),
(PREF_ALEXA_REPORT_STATE, alexa_report_state),
(PREF_GOOGLE_REPORT_STATE, google_report_state),
(PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version),
(PREF_GOOGLE_SETTINGS_VERSION, google_settings_version),
(PREF_TTS_DEFAULT_VOICE, tts_default_voice),
(PREF_REMOTE_DOMAIN, remote_domain),
(PREF_GOOGLE_CONNECTED, google_connected),
(PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
(PREF_CLOUD_USER, cloud_user),
(PREF_CLOUDHOOKS, cloudhooks),
(PREF_ENABLE_ALEXA, alexa_enabled),
(PREF_ENABLE_CLOUD_ICE_SERVERS, cloud_ice_servers_enabled),
(PREF_ENABLE_GOOGLE, google_enabled),
(PREF_ENABLE_REMOTE, remote_enabled),
(PREF_GOOGLE_CONNECTED, google_connected),
(PREF_GOOGLE_REPORT_STATE, google_report_state),
(PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
(PREF_GOOGLE_SETTINGS_VERSION, google_settings_version),
(PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
(PREF_REMOTE_DOMAIN, remote_domain),
(PREF_TTS_DEFAULT_VOICE, tts_default_voice),
)
if value is not UNDEFINED
}
@ -242,6 +242,7 @@ class CloudPreferences:
PREF_ALEXA_REPORT_STATE: self.alexa_report_state,
PREF_CLOUDHOOKS: self.cloudhooks,
PREF_ENABLE_ALEXA: self.alexa_enabled,
PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled,
PREF_ENABLE_GOOGLE: self.google_enabled,
PREF_ENABLE_REMOTE: self.remote_enabled,
PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose,
@ -249,7 +250,6 @@ class CloudPreferences:
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable,
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled,
}
@property

View file

@ -168,7 +168,7 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_create_entry(
title=get_extra_name(data) or "CO2 Signal",
title=get_extra_name(data) or "Electricity Maps",
data=data,
)

View file

@ -4,5 +4,5 @@
"codeowners": ["@Petro31"],
"documentation": "https://www.home-assistant.io/integrations/compensation",
"iot_class": "calculated",
"requirements": ["numpy==2.1.2"]
"requirements": ["numpy==2.1.3"]
}

View file

@ -16,11 +16,11 @@ from hassil.expression import Expression, ListReference, Sequence
from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList
from hassil.recognize import (
MISSING_ENTITY,
MatchEntity,
RecognizeResult,
UnmatchedTextEntity,
recognize_all,
recognize_best,
)
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
from hassil.util import merge_dict
from home_assistant_intents import ErrorKey, get_intents, get_languages
import yaml
@ -294,7 +294,7 @@ class DefaultAgent(ConversationEntity):
self.hass, language, DOMAIN, [DOMAIN]
)
response_text = translations.get(
f"component.{DOMAIN}.agent.done", "Done"
f"component.{DOMAIN}.conversation.agent.done", "Done"
)
response.async_set_speech(response_text)
@ -499,6 +499,7 @@ class DefaultAgent(ConversationEntity):
maybe_result: RecognizeResult | None = None
best_num_matched_entities = 0
best_num_unmatched_entities = 0
best_num_unmatched_ranges = 0
for result in recognize_all(
user_input.text,
lang_intents.intents,
@ -517,10 +518,14 @@ class DefaultAgent(ConversationEntity):
num_matched_entities += 1
num_unmatched_entities = 0
num_unmatched_ranges = 0
for unmatched_entity in result.unmatched_entities_list:
if isinstance(unmatched_entity, UnmatchedTextEntity):
if unmatched_entity.text != MISSING_ENTITY:
num_unmatched_entities += 1
elif isinstance(unmatched_entity, UnmatchedRangeEntity):
num_unmatched_ranges += 1
num_unmatched_entities += 1
else:
num_unmatched_entities += 1
@ -532,15 +537,24 @@ class DefaultAgent(ConversationEntity):
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities < best_num_unmatched_entities)
)
or (
# Prefer unmatched ranges
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges > best_num_unmatched_ranges)
)
or (
# More literal text matched
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (result.text_chunks_matched > maybe_result.text_chunks_matched)
)
or (
# Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (
("name" in result.entities)
or ("name" in result.unmatched_entities)
@ -550,6 +564,7 @@ class DefaultAgent(ConversationEntity):
maybe_result = result
best_num_matched_entities = num_matched_entities
best_num_unmatched_entities = num_unmatched_entities
best_num_unmatched_ranges = num_unmatched_ranges
return maybe_result
@ -562,76 +577,15 @@ class DefaultAgent(ConversationEntity):
language: str,
) -> RecognizeResult | None:
"""Search intents for a strict match to user input."""
custom_found = False
name_found = False
best_results: list[RecognizeResult] = []
best_name_quality: int | None = None
best_text_chunks_matched: int | None = None
for result in recognize_all(
return recognize_best(
user_input.text,
lang_intents.intents,
slot_lists=slot_lists,
intent_context=intent_context,
language=language,
):
# Prioritize user intents
is_custom = (
result.intent_metadata is not None
and result.intent_metadata.get(METADATA_CUSTOM_SENTENCE)
)
if custom_found and not is_custom:
continue
if not custom_found and is_custom:
custom_found = True
# Clear builtin results
name_found = False
best_results = []
best_name_quality = None
best_text_chunks_matched = None
# Prioritize results with a "name" slot
name = result.entities.get("name")
is_name = name and not name.is_wildcard
if name_found and not is_name:
continue
if not name_found and is_name:
name_found = True
# Clear non-name results
best_results = []
best_text_chunks_matched = None
if is_name:
# Prioritize results with a better "name" slot
name_quality = len(cast(MatchEntity, name).value.split())
if (best_name_quality is None) or (name_quality > best_name_quality):
best_name_quality = name_quality
# Clear worse name results
best_results = []
best_text_chunks_matched = None
elif name_quality < best_name_quality:
continue
# Prioritize results with more literal text
# This causes wildcards to match last.
if (best_text_chunks_matched is None) or (
result.text_chunks_matched > best_text_chunks_matched
):
best_results = [result]
best_text_chunks_matched = result.text_chunks_matched
elif result.text_chunks_matched == best_text_chunks_matched:
# Accumulate results with the same number of literal text matched.
# We will resolve the ambiguity below.
best_results.append(result)
if best_results:
# Successful strict match
return best_results[0]
return None
best_metadata_key=METADATA_CUSTOM_SENTENCE,
best_slot_name="name",
)
async def _build_speech(
self,

View file

@ -6,12 +6,8 @@ from collections.abc import Iterable
from typing import Any
from aiohttp import web
from hassil.recognize import (
MISSING_ENTITY,
RecognizeResult,
UnmatchedRangeEntity,
UnmatchedTextEntity,
)
from hassil.recognize import MISSING_ENTITY, RecognizeResult
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
import voluptuous as vol
from homeassistant.components import http, websocket_api

View file

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

View file

@ -4,7 +4,8 @@ from __future__ import annotations
from typing import Any
from hassil.recognize import PUNCTUATION, RecognizeResult
from hassil.recognize import RecognizeResult
from hassil.util import PUNCTUATION_ALL
import voluptuous as vol
from homeassistant.const import CONF_COMMAND, CONF_PLATFORM
@ -20,7 +21,7 @@ from .const import DATA_DEFAULT_ENTITY, DOMAIN
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
for sentence in value:
if PUNCTUATION.search(sentence):
if PUNCTUATION_ALL.search(sentence):
raise vol.Invalid("sentence should not contain punctuation")
return value

View file

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/doods",
"iot_class": "local_polling",
"loggers": ["pydoods"],
"requirements": ["pydoods==1.0.2", "Pillow==10.4.0"]
"requirements": ["pydoods==1.0.2", "Pillow==11.0.0"]
}

View file

@ -6,9 +6,14 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTime
from homeassistant.const import UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -54,21 +59,30 @@ async def async_setup_entry(
) -> None:
"""Set up the ecobee thermostat number entity."""
data: EcobeeData = hass.data[DOMAIN]
_LOGGER.debug("Adding min time ventilators numbers (if present)")
async_add_entities(
assert data is not None
entities: list[NumberEntity] = [
EcobeeVentilatorMinTime(data, index, numbers)
for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["ventilatorType"] != "none"
for numbers in VENTILATOR_NUMBERS
]
_LOGGER.debug("Adding compressor min temp number (if present)")
entities.extend(
(
EcobeeVentilatorMinTime(data, index, numbers)
EcobeeCompressorMinTemp(data, index)
for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["ventilatorType"] != "none"
for numbers in VENTILATOR_NUMBERS
),
True,
if thermostat["settings"]["hasHeatPump"]
)
)
async_add_entities(entities, True)
class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity):
"""A number class, representing min time for an ecobee thermostat with ventilator attached."""
"""A number class, representing min time for an ecobee thermostat with ventilator attached."""
entity_description: EcobeeNumberEntityDescription
@ -105,3 +119,53 @@ class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity):
"""Set new ventilator Min On Time value."""
self.entity_description.set_fn(self.data, self.thermostat_index, int(value))
self.update_without_throttle = True
class EcobeeCompressorMinTemp(EcobeeBaseEntity, NumberEntity):
"""Minimum outdoor temperature at which the compressor will operate.
This applies more to air source heat pumps than geothermal. This serves as a safety
feature (compressors have a minimum operating temperature) as well as
providing the ability to choose fuel in a dual-fuel system (i.e. choose between
electrical heat pump and fossil auxiliary heat depending on Time of Use, Solar,
etc.).
Note that python-ecobee-api refers to this as Aux Cutover Threshold, but Ecobee
uses Compressor Protection Min Temp.
"""
_attr_device_class = NumberDeviceClass.TEMPERATURE
_attr_has_entity_name = True
_attr_icon = "mdi:thermometer-off"
_attr_mode = NumberMode.BOX
_attr_native_min_value = -25
_attr_native_max_value = 66
_attr_native_step = 5
_attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT
_attr_translation_key = "compressor_protection_min_temp"
def __init__(
self,
data: EcobeeData,
thermostat_index: int,
) -> None:
"""Initialize ecobee compressor min temperature."""
super().__init__(data, thermostat_index)
self._attr_unique_id = f"{self.base_unique_id}_compressor_protection_min_temp"
self.update_without_throttle = False
async def async_update(self) -> None:
"""Get the latest state from the thermostat."""
if self.update_without_throttle:
await self.data.update(no_throttle=True)
self.update_without_throttle = False
else:
await self.data.update()
self._attr_native_value = (
(self.thermostat["settings"]["compressorProtectionMinTemp"]) / 10
)
def set_native_value(self, value: float) -> None:
"""Set new compressor minimum temperature."""
self.data.ecobee.set_aux_cutover_threshold(self.thermostat_index, value)
self.update_without_throttle = True

View file

@ -33,15 +33,18 @@
},
"number": {
"ventilator_min_type_home": {
"name": "Ventilator min time home"
"name": "Ventilator minimum time home"
},
"ventilator_min_type_away": {
"name": "Ventilator min time away"
"name": "Ventilator minimum time away"
},
"compressor_protection_min_temp": {
"name": "Compressor minimum temperature"
}
},
"switch": {
"aux_heat_only": {
"name": "Aux heat only"
"name": "Auxiliary heat only"
}
}
},

View file

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

View file

@ -5,8 +5,11 @@ from pyemoncms import EmoncmsClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN, EMONCMS_UUID_DOC_URL, LOGGER
from .coordinator import EmoncmsCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
@ -14,6 +17,49 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator]
def _migrate_unique_id(
hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_unique_id: str
) -> None:
"""Migrate to emoncms unique id if needed."""
ent_reg = er.async_get(hass)
entry_entities = ent_reg.entities.get_entries_for_config_entry_id(entry.entry_id)
for entity in entry_entities:
if entity.unique_id.split("-")[0] == entry.entry_id:
feed_id = entity.unique_id.split("-")[-1]
LOGGER.debug(f"moving feed {feed_id} to hardware uuid")
ent_reg.async_update_entity(
entity.entity_id, new_unique_id=f"{emoncms_unique_id}-{feed_id}"
)
hass.config_entries.async_update_entry(
entry,
unique_id=emoncms_unique_id,
)
async def _check_unique_id_migration(
hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_client: EmoncmsClient
) -> None:
"""Check if we can migrate to the emoncms uuid."""
emoncms_unique_id = await emoncms_client.async_get_uuid()
if emoncms_unique_id:
if entry.unique_id != emoncms_unique_id:
_migrate_unique_id(hass, entry, emoncms_unique_id)
else:
async_create_issue(
hass,
DOMAIN,
"migrate database",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="migrate_database",
translation_placeholders={
"url": entry.data[CONF_URL],
"doc_url": EMONCMS_UUID_DOC_URL,
},
)
async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool:
"""Load a config entry."""
emoncms_client = EmoncmsClient(
@ -21,6 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b
entry.data[CONF_API_KEY],
session=async_get_clientsession(hass),
)
await _check_unique_id_migration(hass, entry, emoncms_client)
coordinator = EmoncmsCoordinator(hass, emoncms_client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

View file

@ -14,7 +14,7 @@ from homeassistant.config_entries import (
OptionsFlow,
)
from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import selector
from homeassistant.helpers.typing import ConfigType
@ -48,13 +48,10 @@ def sensor_name(url: str) -> str:
return f"emoncms@{sensorip}"
async def get_feed_list(hass: HomeAssistant, url: str, api_key: str) -> dict[str, Any]:
async def get_feed_list(
emoncms_client: EmoncmsClient,
) -> dict[str, Any]:
"""Check connection to emoncms and return feed list if successful."""
emoncms_client = EmoncmsClient(
url,
api_key,
session=async_get_clientsession(hass),
)
return await emoncms_client.async_request("/feed/list.json")
@ -82,22 +79,25 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders = {}
if user_input is not None:
self.url = user_input[CONF_URL]
self.api_key = user_input[CONF_API_KEY]
self._async_abort_entries_match(
{
CONF_API_KEY: user_input[CONF_API_KEY],
CONF_URL: user_input[CONF_URL],
CONF_API_KEY: self.api_key,
CONF_URL: self.url,
}
)
result = await get_feed_list(
self.hass, user_input[CONF_URL], user_input[CONF_API_KEY]
emoncms_client = EmoncmsClient(
self.url, self.api_key, session=async_get_clientsession(self.hass)
)
result = await get_feed_list(emoncms_client)
if not result[CONF_SUCCESS]:
errors["base"] = "api_error"
description_placeholders = {"details": result[CONF_MESSAGE]}
else:
self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID)
self.url = user_input[CONF_URL]
self.api_key = user_input[CONF_API_KEY]
await self.async_set_unique_id(await emoncms_client.async_get_uuid())
self._abort_if_unique_id_configured()
options = get_options(result[CONF_MESSAGE])
self.dropdown = {
"options": options,
@ -191,7 +191,12 @@ class EmoncmsOptionsFlow(OptionsFlow):
self.config_entry.data.get(CONF_ONLY_INCLUDE_FEEDID, []),
)
options: list = include_only_feeds
result = await get_feed_list(self.hass, self._url, self._api_key)
emoncms_client = EmoncmsClient(
self._url,
self._api_key,
session=async_get_clientsession(self.hass),
)
result = await get_feed_list(emoncms_client)
if not result[CONF_SUCCESS]:
errors["base"] = "api_error"
description_placeholders = {"details": result[CONF_MESSAGE]}

View file

@ -7,6 +7,10 @@ CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id"
CONF_MESSAGE = "message"
CONF_SUCCESS = "success"
DOMAIN = "emoncms"
EMONCMS_UUID_DOC_URL = (
"https://docs.openenergymonitor.org/emoncms/update.html"
"#upgrading-to-a-version-producing-a-unique-identifier"
)
FEED_ID = "id"
FEED_NAME = "name"
FEED_TAG = "tag"

View file

@ -148,20 +148,20 @@ async def async_setup_entry(
return
coordinator = entry.runtime_data
# uuid was added in emoncms database 11.5.7
unique_id = entry.unique_id if entry.unique_id else entry.entry_id
elems = coordinator.data
if not elems:
return
sensors: list[EmonCmsSensor] = []
for idx, elem in enumerate(elems):
if include_only_feeds is not None and elem[FEED_ID] not in include_only_feeds:
continue
sensors.append(
EmonCmsSensor(
coordinator,
entry.entry_id,
unique_id,
elem["unit"],
name,
idx,
@ -176,7 +176,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
def __init__(
self,
coordinator: EmoncmsCoordinator,
entry_id: str,
unique_id: str,
unit_of_measurement: str | None,
name: str,
idx: int,
@ -189,7 +189,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
elem = self.coordinator.data[self.idx]
self._attr_name = f"{name} {elem[FEED_NAME]}"
self._attr_native_unit_of_measurement = unit_of_measurement
self._attr_unique_id = f"{entry_id}-{elem[FEED_ID]}"
self._attr_unique_id = f"{unique_id}-{elem[FEED_ID]}"
if unit_of_measurement in ("kWh", "Wh"):
self._attr_device_class = SensorDeviceClass.ENERGY
self._attr_state_class = SensorStateClass.TOTAL_INCREASING

View file

@ -19,6 +19,9 @@
"include_only_feed_id": "Choose feeds to include"
}
}
},
"abort": {
"already_configured": "This server is already configured"
}
},
"options": {
@ -41,6 +44,10 @@
"missing_include_only_feed_id": {
"title": "No feed synchronized with the {domain} sensor",
"description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration."
},
"migrate_database": {
"title": "Upgrade your emoncms version",
"description": "Your [emoncms]({url}) does not ship a unique identifier.\n\n Please upgrade to at least version 11.5.7 and migrate your emoncms database.\n\n More info on [emoncms documentation]({doc_url})"
}
}
}

View file

@ -15,17 +15,23 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
from .models import Eq3Config, Eq3ConfigEntryData
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.NUMBER,
Platform.SWITCH,
]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
type Eq3ConfigEntry = ConfigEntry[Eq3ConfigEntryData]
async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
"""Handle config entry setup."""
mac_address: str | None = entry.unique_id
@ -53,12 +59,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
ble_device=device,
)
eq3_config_entry = Eq3ConfigEntryData(eq3_config=eq3_config, thermostat=thermostat)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = eq3_config_entry
entry.runtime_data = Eq3ConfigEntryData(
eq3_config=eq3_config, thermostat=thermostat
)
entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_create_background_task(
hass, _async_run_thermostat(hass, entry), entry.entry_id
)
@ -66,29 +71,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
"""Handle config entry unload."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN].pop(entry.entry_id)
await eq3_config_entry.thermostat.async_disconnect()
await entry.runtime_data.thermostat.async_disconnect()
return unload_ok
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def update_listener(hass: HomeAssistant, entry: Eq3ConfigEntry) -> None:
"""Handle config entry update."""
await hass.config_entries.async_reload(entry.entry_id)
async def _async_run_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def _async_run_thermostat(hass: HomeAssistant, entry: Eq3ConfigEntry) -> None:
"""Run the thermostat."""
eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][entry.entry_id]
thermostat = eq3_config_entry.thermostat
mac_address = eq3_config_entry.eq3_config.mac_address
scan_interval = eq3_config_entry.eq3_config.scan_interval
thermostat = entry.runtime_data.thermostat
mac_address = entry.runtime_data.eq3_config.mac_address
scan_interval = entry.runtime_data.eq3_config.scan_interval
await _async_reconnect_thermostat(hass, entry)
@ -117,13 +120,14 @@ async def _async_run_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None
await asyncio.sleep(scan_interval)
async def _async_reconnect_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def _async_reconnect_thermostat(
hass: HomeAssistant, entry: Eq3ConfigEntry
) -> None:
"""Reconnect the thermostat."""
eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][entry.entry_id]
thermostat = eq3_config_entry.thermostat
mac_address = eq3_config_entry.eq3_config.mac_address
scan_interval = eq3_config_entry.eq3_config.scan_interval
thermostat = entry.runtime_data.thermostat
mac_address = entry.runtime_data.eq3_config.mac_address
scan_interval = entry.runtime_data.eq3_config.scan_interval
while True:
try:

View file

@ -0,0 +1,86 @@
"""Platform for eq3 binary sensor entities."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from eq3btsmart.models import Status
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Eq3ConfigEntry
from .const import ENTITY_KEY_BATTERY, ENTITY_KEY_DST, ENTITY_KEY_WINDOW
from .entity import Eq3Entity
@dataclass(frozen=True, kw_only=True)
class Eq3BinarySensorEntityDescription(BinarySensorEntityDescription):
"""Entity description for eq3 binary sensors."""
value_func: Callable[[Status], bool]
BINARY_SENSOR_ENTITY_DESCRIPTIONS = [
Eq3BinarySensorEntityDescription(
value_func=lambda status: status.is_low_battery,
key=ENTITY_KEY_BATTERY,
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
),
Eq3BinarySensorEntityDescription(
value_func=lambda status: status.is_window_open,
key=ENTITY_KEY_WINDOW,
device_class=BinarySensorDeviceClass.WINDOW,
),
Eq3BinarySensorEntityDescription(
value_func=lambda status: status.is_dst,
key=ENTITY_KEY_DST,
translation_key=ENTITY_KEY_DST,
entity_category=EntityCategory.DIAGNOSTIC,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the entry."""
async_add_entities(
Eq3BinarySensorEntity(entry, entity_description)
for entity_description in BINARY_SENSOR_ENTITY_DESCRIPTIONS
)
class Eq3BinarySensorEntity(Eq3Entity, BinarySensorEntity):
"""Base class for eQ-3 binary sensor entities."""
entity_description: Eq3BinarySensorEntityDescription
def __init__(
self,
entry: Eq3ConfigEntry,
entity_description: Eq3BinarySensorEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(entry, entity_description.key)
self.entity_description = entity_description
@property
def is_on(self) -> bool:
"""Return the state of the binary sensor."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
return self.entity_description.value_func(self._thermostat.status)

View file

@ -3,7 +3,6 @@
import logging
from typing import Any
from eq3btsmart import Thermostat
from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode
from eq3btsmart.exceptions import Eq3Exception
@ -15,45 +14,35 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify
from . import Eq3ConfigEntry
from .const import (
DEVICE_MODEL,
DOMAIN,
EQ_TO_HA_HVAC,
HA_TO_EQ_HVAC,
MANUFACTURER,
SIGNAL_THERMOSTAT_CONNECTED,
SIGNAL_THERMOSTAT_DISCONNECTED,
CurrentTemperatureSelector,
Preset,
TargetTemperatureSelector,
)
from .entity import Eq3Entity
from .models import Eq3Config, Eq3ConfigEntryData
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Handle config entry setup."""
eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
[Eq3Climate(eq3_config_entry.eq3_config, eq3_config_entry.thermostat)],
[Eq3Climate(entry)],
)
@ -80,53 +69,6 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
_attr_preset_mode: str | None = None
_target_temperature: float | None = None
def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat) -> None:
"""Initialize the climate entity."""
super().__init__(eq3_config, thermostat)
self._attr_unique_id = dr.format_mac(eq3_config.mac_address)
self._attr_device_info = DeviceInfo(
name=slugify(self._eq3_config.mac_address),
manufacturer=MANUFACTURER,
model=DEVICE_MODEL,
connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self._thermostat.register_update_callback(self._async_on_updated)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_THERMOSTAT_DISCONNECTED}_{self._eq3_config.mac_address}",
self._async_on_disconnected,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_THERMOSTAT_CONNECTED}_{self._eq3_config.mac_address}",
self._async_on_connected,
)
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
self._thermostat.unregister_update_callback(self._async_on_updated)
@callback
def _async_on_disconnected(self) -> None:
self._attr_available = False
self.async_write_ha_state()
@callback
def _async_on_connected(self) -> None:
self._attr_available = True
self.async_write_ha_state()
@callback
def _async_on_updated(self) -> None:
"""Handle updated data from the thermostat."""
@ -137,12 +79,15 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
if self._thermostat.device_data is not None:
self._async_on_device_updated()
self.async_write_ha_state()
super()._async_on_updated()
@callback
def _async_on_status_updated(self) -> None:
"""Handle updated status from the thermostat."""
if self._thermostat.status is None:
return
self._target_temperature = self._thermostat.status.target_temperature.value
self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode]
self._attr_current_temperature = self._get_current_temperature()
@ -154,13 +99,16 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
def _async_on_device_updated(self) -> None:
"""Handle updated device data from the thermostat."""
if self._thermostat.device_data is None:
return
device_registry = dr.async_get(self.hass)
if device := device_registry.async_get_device(
connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
):
device_registry.async_update_device(
device.id,
sw_version=self._thermostat.device_data.firmware_version,
sw_version=str(self._thermostat.device_data.firmware_version),
serial_number=self._thermostat.device_data.device_serial.value,
)
@ -265,7 +213,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
self.async_write_ha_state()
try:
await self._thermostat.async_set_temperature(self._target_temperature)
await self._thermostat.async_set_temperature(temperature)
except Eq3Exception:
_LOGGER.error(
"[%s] Failed setting temperature", self._eq3_config.mac_address

View file

@ -18,8 +18,19 @@ DOMAIN = "eq3btsmart"
MANUFACTURER = "eQ-3 AG"
DEVICE_MODEL = "CC-RT-BLE-EQ"
GET_DEVICE_TIMEOUT = 5 # seconds
ENTITY_KEY_DST = "dst"
ENTITY_KEY_BATTERY = "battery"
ENTITY_KEY_WINDOW = "window"
ENTITY_KEY_LOCK = "lock"
ENTITY_KEY_BOOST = "boost"
ENTITY_KEY_AWAY = "away"
ENTITY_KEY_COMFORT = "comfort"
ENTITY_KEY_ECO = "eco"
ENTITY_KEY_OFFSET = "offset"
ENTITY_KEY_WINDOW_OPEN_TEMPERATURE = "window_open_temperature"
ENTITY_KEY_WINDOW_OPEN_TIMEOUT = "window_open_timeout"
GET_DEVICE_TIMEOUT = 5 # seconds
EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = {
OperationMode.OFF: HVACMode.OFF,
@ -71,3 +82,5 @@ DEFAULT_SCAN_INTERVAL = 10 # seconds
SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected"
SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected"
EQ3BT_STEP = 0.5

View file

@ -1,10 +1,22 @@
"""Base class for all eQ-3 entities."""
from eq3btsmart.thermostat import Thermostat
from homeassistant.core import callback
from homeassistant.helpers.device_registry import (
CONNECTION_BLUETOOTH,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.util import slugify
from .models import Eq3Config
from . import Eq3ConfigEntry
from .const import (
DEVICE_MODEL,
MANUFACTURER,
SIGNAL_THERMOSTAT_CONNECTED,
SIGNAL_THERMOSTAT_DISCONNECTED,
)
class Eq3Entity(Entity):
@ -12,8 +24,70 @@ class Eq3Entity(Entity):
_attr_has_entity_name = True
def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat) -> None:
def __init__(
self,
entry: Eq3ConfigEntry,
unique_id_key: str | None = None,
) -> None:
"""Initialize the eq3 entity."""
self._eq3_config = eq3_config
self._thermostat = thermostat
self._eq3_config = entry.runtime_data.eq3_config
self._thermostat = entry.runtime_data.thermostat
self._attr_device_info = DeviceInfo(
name=slugify(self._eq3_config.mac_address),
manufacturer=MANUFACTURER,
model=DEVICE_MODEL,
connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
)
suffix = f"_{unique_id_key}" if unique_id_key else ""
self._attr_unique_id = f"{format_mac(self._eq3_config.mac_address)}{suffix}"
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self._thermostat.register_update_callback(self._async_on_updated)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_THERMOSTAT_DISCONNECTED}_{self._eq3_config.mac_address}",
self._async_on_disconnected,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_THERMOSTAT_CONNECTED}_{self._eq3_config.mac_address}",
self._async_on_connected,
)
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
self._thermostat.unregister_update_callback(self._async_on_updated)
def _async_on_updated(self) -> None:
"""Handle updated data from the thermostat."""
self.async_write_ha_state()
@callback
def _async_on_disconnected(self) -> None:
"""Handle disconnection from the thermostat."""
self._attr_available = False
self.async_write_ha_state()
@callback
def _async_on_connected(self) -> None:
"""Handle connection to the thermostat."""
self._attr_available = True
self.async_write_ha_state()
@property
def available(self) -> bool:
"""Whether the entity is available."""
return self._thermostat.status is not None and self._attr_available

View file

@ -0,0 +1,49 @@
{
"entity": {
"binary_sensor": {
"dst": {
"default": "mdi:sun-clock",
"state": {
"off": "mdi:sun-clock-outline"
}
}
},
"number": {
"comfort": {
"default": "mdi:sun-thermometer"
},
"eco": {
"default": "mdi:snowflake-thermometer"
},
"offset": {
"default": "mdi:thermometer-plus"
},
"window_open_temperature": {
"default": "mdi:window-open-variant"
},
"window_open_timeout": {
"default": "mdi:timer-refresh"
}
},
"switch": {
"away": {
"default": "mdi:home-account",
"state": {
"on": "mdi:home-export"
}
},
"lock": {
"default": "mdi:lock",
"state": {
"off": "mdi:lock-off"
}
},
"boost": {
"default": "mdi:fire",
"state": {
"off": "mdi:fire-off"
}
}
}
}
}

View file

@ -23,5 +23,5 @@
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"quality_scale": "silver",
"requirements": ["eq3btsmart==1.2.0", "bleak-esphome==1.1.0"]
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"]
}

View file

@ -2,7 +2,6 @@
from dataclasses import dataclass
from eq3btsmart.const import DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP
from eq3btsmart.thermostat import Thermostat
from .const import (
@ -23,8 +22,6 @@ class Eq3Config:
target_temp_selector: TargetTemperatureSelector = DEFAULT_TARGET_TEMP_SELECTOR
external_temp_sensor: str = ""
scan_interval: int = DEFAULT_SCAN_INTERVAL
default_away_hours: float = DEFAULT_AWAY_HOURS
default_away_temperature: float = DEFAULT_AWAY_TEMP
@dataclass(slots=True)

View file

@ -0,0 +1,158 @@
"""Platform for eq3 number entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from eq3btsmart import Thermostat
from eq3btsmart.const import (
EQ3BT_MAX_OFFSET,
EQ3BT_MAX_TEMP,
EQ3BT_MIN_OFFSET,
EQ3BT_MIN_TEMP,
)
from eq3btsmart.models import Presets
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Eq3ConfigEntry
from .const import (
ENTITY_KEY_COMFORT,
ENTITY_KEY_ECO,
ENTITY_KEY_OFFSET,
ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
EQ3BT_STEP,
)
from .entity import Eq3Entity
@dataclass(frozen=True, kw_only=True)
class Eq3NumberEntityDescription(NumberEntityDescription):
"""Entity description for eq3 number entities."""
value_func: Callable[[Presets], float]
value_set_func: Callable[
[Thermostat],
Callable[[float], Awaitable[None]],
]
mode: NumberMode = NumberMode.BOX
entity_category: EntityCategory | None = EntityCategory.CONFIG
NUMBER_ENTITY_DESCRIPTIONS = [
Eq3NumberEntityDescription(
key=ENTITY_KEY_COMFORT,
value_func=lambda presets: presets.comfort_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature,
translation_key=ENTITY_KEY_COMFORT,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_ECO,
value_func=lambda presets: presets.eco_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature,
translation_key=ENTITY_KEY_ECO,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
value_func=lambda presets: presets.window_open_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature,
translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_OFFSET,
value_func=lambda presets: presets.offset_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset,
translation_key=ENTITY_KEY_OFFSET,
native_min_value=EQ3BT_MIN_OFFSET,
native_max_value=EQ3BT_MAX_OFFSET,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration,
value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60,
translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
native_min_value=0,
native_max_value=60,
native_step=5,
native_unit_of_measurement=UnitOfTime.MINUTES,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the entry."""
async_add_entities(
Eq3NumberEntity(entry, entity_description)
for entity_description in NUMBER_ENTITY_DESCRIPTIONS
)
class Eq3NumberEntity(Eq3Entity, NumberEntity):
"""Base class for all eq3 number entities."""
entity_description: Eq3NumberEntityDescription
def __init__(
self, entry: Eq3ConfigEntry, entity_description: Eq3NumberEntityDescription
) -> None:
"""Initialize the entity."""
super().__init__(entry, entity_description.key)
self.entity_description = entity_description
@property
def native_value(self) -> float:
"""Return the state of the entity."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
assert self._thermostat.status.presets is not None
return self.entity_description.value_func(self._thermostat.status.presets)
async def async_set_native_value(self, value: float) -> None:
"""Set the state of the entity."""
await self.entity_description.value_set_func(self._thermostat)(value)
@property
def available(self) -> bool:
"""Return whether the entity is available."""
return (
self._thermostat.status is not None
and self._thermostat.status.presets is not None
and self._attr_available
)

View file

@ -18,5 +18,40 @@
"error": {
"invalid_mac_address": "Invalid MAC address"
}
},
"entity": {
"binary_sensor": {
"dst": {
"name": "Daylight saving time"
}
},
"number": {
"comfort": {
"name": "Comfort temperature"
},
"eco": {
"name": "Eco temperature"
},
"offset": {
"name": "Offset temperature"
},
"window_open_temperature": {
"name": "Window open temperature"
},
"window_open_timeout": {
"name": "Window open timeout"
}
},
"switch": {
"lock": {
"name": "Lock"
},
"boost": {
"name": "Boost"
},
"away": {
"name": "Away"
}
}
}
}

View file

@ -0,0 +1,94 @@
"""Platform for eq3 switch entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from eq3btsmart import Thermostat
from eq3btsmart.models import Status
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Eq3ConfigEntry
from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK
from .entity import Eq3Entity
@dataclass(frozen=True, kw_only=True)
class Eq3SwitchEntityDescription(SwitchEntityDescription):
"""Entity description for eq3 switch entities."""
toggle_func: Callable[[Thermostat], Callable[[bool], Awaitable[None]]]
value_func: Callable[[Status], bool]
SWITCH_ENTITY_DESCRIPTIONS = [
Eq3SwitchEntityDescription(
key=ENTITY_KEY_LOCK,
translation_key=ENTITY_KEY_LOCK,
toggle_func=lambda thermostat: thermostat.async_set_locked,
value_func=lambda status: status.is_locked,
),
Eq3SwitchEntityDescription(
key=ENTITY_KEY_BOOST,
translation_key=ENTITY_KEY_BOOST,
toggle_func=lambda thermostat: thermostat.async_set_boost,
value_func=lambda status: status.is_boost,
),
Eq3SwitchEntityDescription(
key=ENTITY_KEY_AWAY,
translation_key=ENTITY_KEY_AWAY,
toggle_func=lambda thermostat: thermostat.async_set_away,
value_func=lambda status: status.is_away,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the entry."""
async_add_entities(
Eq3SwitchEntity(entry, entity_description)
for entity_description in SWITCH_ENTITY_DESCRIPTIONS
)
class Eq3SwitchEntity(Eq3Entity, SwitchEntity):
"""Base class for eq3 switch entities."""
entity_description: Eq3SwitchEntityDescription
def __init__(
self,
entry: Eq3ConfigEntry,
entity_description: Eq3SwitchEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(entry, entity_description.key)
self.entity_description = entity_description
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
await self.entity_description.toggle_func(self._thermostat)(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
await self.entity_description.toggle_func(self._thermostat)(False)
@property
def is_on(self) -> bool:
"""Return the state of the switch."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
return self.entity_description.value_func(self._thermostat.status)

View file

@ -73,11 +73,9 @@ class EzvizUpdateEntity(EzvizEntity, UpdateEntity):
return self.data["version"]
@property
def in_progress(self) -> bool | int | None:
def in_progress(self) -> bool:
"""Update installation progress."""
if self.data["upgrade_in_progress"]:
return self.data["upgrade_percent"]
return False
return bool(self.data["upgrade_in_progress"])
@property
def latest_version(self) -> str | None:
@ -93,6 +91,13 @@ class EzvizUpdateEntity(EzvizEntity, UpdateEntity):
return self.data["latest_firmware_info"].get("desc")
return None
@property
def update_percentage(self) -> int | None:
"""Update installation progress."""
if self.data["upgrade_in_progress"]:
return self.data["upgrade_percent"]
return None
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:

View file

@ -4,5 +4,5 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/ffmpeg",
"integration_type": "system",
"requirements": ["ha-ffmpeg==3.2.1"]
"requirements": ["ha-ffmpeg==3.2.2"]
}

View file

@ -3,88 +3,16 @@
from copy import deepcopy
from typing import Any
from homeassistant.components.notify import migrate_notify_issue
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_FILE_PATH,
CONF_NAME,
CONF_PLATFORM,
CONF_SCAN_INTERVAL,
Platform,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import (
config_validation as cv,
discovery,
issue_registry as ir,
)
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .notify import PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA
from .sensor import PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA
IMPORT_SCHEMA = {
Platform.SENSOR: SENSOR_PLATFORM_SCHEMA,
Platform.NOTIFY: NOTIFY_PLATFORM_SCHEMA,
}
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the file integration."""
hass.data[DOMAIN] = config
if hass.config_entries.async_entries(DOMAIN):
# We skip import in case we already have config entries
return True
# The use of the legacy notify service was deprecated with HA Core 2024.6.0
# and will be removed with HA Core 2024.12
migrate_notify_issue(hass, DOMAIN, "File", "2024.12.0")
# The YAML config was imported with HA Core 2024.6.0 and will be removed with
# HA Core 2024.12
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2024.12.0",
is_fixable=False,
issue_domain=DOMAIN,
learn_more_url="https://www.home-assistant.io/integrations/file/",
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "File",
},
)
# Import the YAML config into separate config entries
platforms_config: dict[Platform, list[ConfigType]] = {
domain: config[domain] for domain in PLATFORMS if domain in config
}
for domain, items in platforms_config.items():
for item in items:
if item[CONF_PLATFORM] == DOMAIN:
file_config_item = IMPORT_SCHEMA[domain](item)
file_config_item[CONF_PLATFORM] = domain
if CONF_SCAN_INTERVAL in file_config_item:
del file_config_item[CONF_SCAN_INTERVAL]
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=file_config_item,
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a file component entry."""
config = {**entry.data, **entry.options}
@ -102,20 +30,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, [Platform(entry.data[CONF_PLATFORM])]
)
entry.async_on_unload(entry.add_update_listener(update_listener))
if entry.data[CONF_PLATFORM] == Platform.NOTIFY and CONF_NAME in entry.data:
# New notify entities are being setup through the config entry,
# but during the deprecation period we want to keep the legacy notify platform,
# so we forward the setup config through discovery.
# Only the entities from yaml will still be available as legacy service.
hass.async_create_task(
discovery.async_load_platform(
hass,
Platform.NOTIFY,
DOMAIN,
config,
hass.data[DOMAIN],
)
)
return True

View file

@ -3,7 +3,6 @@
from __future__ import annotations
from copy import deepcopy
import os
from typing import Any
import voluptuous as vol
@ -16,7 +15,6 @@ from homeassistant.config_entries import (
)
from homeassistant.const import (
CONF_FILE_PATH,
CONF_FILENAME,
CONF_NAME,
CONF_PLATFORM,
CONF_UNIT_OF_MEASUREMENT,
@ -132,27 +130,6 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle file sensor config flow."""
return await self._async_handle_step(Platform.SENSOR.value, user_input)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import `file`` config from configuration.yaml."""
self._async_abort_entries_match(import_data)
platform = import_data[CONF_PLATFORM]
name: str = import_data.get(CONF_NAME, DEFAULT_NAME)
file_name: str
if platform == Platform.NOTIFY:
file_name = import_data.pop(CONF_FILENAME)
file_path: str = os.path.join(self.hass.config.config_dir, file_name)
import_data[CONF_FILE_PATH] = file_path
else:
file_path = import_data[CONF_FILE_PATH]
title = f"{name} [{file_path}]"
data = deepcopy(import_data)
options = {}
for key, value in import_data.items():
if key not in (CONF_FILE_PATH, CONF_PLATFORM, CONF_NAME):
data.pop(key)
options[key] = value
return self.async_create_entry(title=title, data=data, options=options)
class FileOptionsFlowHandler(OptionsFlow):
"""Handle File options."""

View file

@ -2,104 +2,23 @@
from __future__ import annotations
from functools import partial
import logging
import os
from typing import Any, TextIO
import voluptuous as vol
from homeassistant.components.notify import (
ATTR_TITLE,
ATTR_TITLE_DEFAULT,
PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA,
BaseNotificationService,
NotifyEntity,
NotifyEntityFeature,
migrate_notify_issue,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME, CONF_NAME
from homeassistant.const import CONF_FILE_PATH, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util
from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON
_LOGGER = logging.getLogger(__name__)
# The legacy platform schema uses a filename, after import
# The full file path is stored in the config entry
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_FILENAME): cv.string,
vol.Optional(CONF_TIMESTAMP, default=False): cv.boolean,
}
)
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> FileNotificationService | None:
"""Get the file notification service."""
if discovery_info is None:
# We only set up through discovery
return None
file_path: str = discovery_info[CONF_FILE_PATH]
timestamp: bool = discovery_info[CONF_TIMESTAMP]
return FileNotificationService(file_path, timestamp)
class FileNotificationService(BaseNotificationService):
"""Implement the notification service for the File service."""
def __init__(self, file_path: str, add_timestamp: bool) -> None:
"""Initialize the service."""
self._file_path = file_path
self.add_timestamp = add_timestamp
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a file."""
# The use of the legacy notify service was deprecated with HA Core 2024.6.0
# and will be removed with HA Core 2024.12
migrate_notify_issue(
self.hass, DOMAIN, "File", "2024.12.0", service_name=self._service_name
)
await self.hass.async_add_executor_job(
partial(self.send_message, message, **kwargs)
)
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a file."""
file: TextIO
filepath = self._file_path
try:
with open(filepath, "a", encoding="utf8") as file:
if os.stat(filepath).st_size == 0:
title = (
f"{kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)} notifications (Log"
f" started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n"
)
file.write(title)
if self.add_timestamp:
text = f"{dt_util.utcnow().isoformat()} {message}\n"
else:
text = f"{message}\n"
file.write(text)
except OSError as exc:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="write_access_failed",
translation_placeholders={"filename": filepath, "exc": f"{exc!r}"},
) from exc
async def async_setup_entry(
hass: HomeAssistant,

View file

@ -6,12 +6,8 @@ import logging
import os
from file_read_backwards import FileReadBackwards
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorEntity,
)
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_FILE_PATH,
@ -20,38 +16,13 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DEFAULT_NAME, FILE_ICON
_LOGGER = logging.getLogger(__name__)
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_FILE_PATH): cv.isfile,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_VALUE_TEMPLATE): cv.string,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
}
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the file sensor from YAML.
The YAML platform config is automatically
imported to a config entry, this method can be removed
when YAML support is removed.
"""
async def async_setup_entry(
hass: HomeAssistant,

View file

@ -18,7 +18,7 @@
},
"data_description": {
"file_path": "The local file path to retrieve the sensor value from",
"value_template": "A template to render the the sensors value based on the file content",
"value_template": "A template to render the sensors value based on the file content",
"unit_of_measurement": "Unit of measurement for the sensor"
}
},

View file

@ -57,6 +57,8 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_host: str
@staticmethod
@callback
def async_get_options_flow(
@ -67,7 +69,6 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize FRITZ!Box Tools flow."""
self._host: str | None = None
self._name: str = ""
self._password: str = ""
self._use_tls: bool = False
@ -112,7 +113,6 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_check_configured_entry(self) -> ConfigEntry | None:
"""Check if entry is configured."""
assert self._host
current_host = await self.hass.async_add_executor_job(
socket.gethostbyname, self._host
)
@ -154,15 +154,17 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "")
self._host = ssdp_location.hostname
host = ssdp_location.hostname
if not host or ipaddress.ip_address(host).is_link_local:
return self.async_abort(reason="ignore_ip6_link_local")
self._host = host
self._name = (
discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME]
)
if not self._host or ipaddress.ip_address(self._host).is_link_local:
return self.async_abort(reason="ignore_ip6_link_local")
uuid: str | None
if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN):
if uuid.startswith("uuid:"):
uuid = uuid[5:]

View file

@ -43,10 +43,11 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
_name: str
def __init__(self) -> None:
"""Initialize flow."""
self._host: str | None = None
self._name: str | None = None
self._password: str | None = None
self._username: str | None = None
@ -158,7 +159,6 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
result = await self.async_try_connect()
if result == RESULT_SUCCESS:
assert self._name is not None
return self._get_entry(self._name)
if result != RESULT_INVALID_AUTH:
return self.async_abort(reason=result)

View file

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

View file

@ -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==10.4.0"]
"requirements": ["av==13.1.0", "Pillow==11.0.0"]
}

View file

@ -4,7 +4,7 @@
"step": {
"user": {
"title": "Add generic hygrostat",
"description": "Create a entity that control the humidity via a switch and sensor.",
"description": "Create a humidifier entity that control the humidity via a switch and sensor.",
"data": {
"device_class": "Device class",
"dry_tolerance": "Dry tolerance",

View file

@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
"title": "Add generic thermostat helper",
"title": "Add generic thermostat",
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
"data": {
"ac_mode": "Cooling mode",
@ -17,8 +17,8 @@
"data_description": {
"ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.",
"heater": "Switch entity used to cool or heat depending on A/C mode.",
"target_sensor": "Temperature sensor that reflect the current temperature.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on. This option will be ignored if the keep alive option is set.",
"target_sensor": "Temperature sensor that reflects the current temperature.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.",
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5."
}

View file

@ -9,7 +9,6 @@ import aiohttp
from geniushubclient import GeniusHub
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
@ -21,20 +20,12 @@ from homeassistant.const import (
CONF_USERNAME,
Platform,
)
from homeassistant.core import (
DOMAIN as HOMEASSISTANT_DOMAIN,
HomeAssistant,
ServiceCall,
callback,
)
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.service import verify_domain_control
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
@ -45,27 +36,6 @@ SCAN_INTERVAL = timedelta(seconds=60)
MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$"
CLOUD_API_SCHEMA = vol.Schema(
{
vol.Required(CONF_TOKEN): cv.string,
vol.Required(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP),
}
)
LOCAL_API_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP),
}
)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Any(LOCAL_API_SCHEMA, CLOUD_API_SCHEMA)}, extra=vol.ALLOW_EXTRA
)
ATTR_ZONE_MODE = "mode"
ATTR_DURATION = "duration"
@ -100,56 +70,6 @@ PLATFORMS = [
]
async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None:
"""Import a config entry from configuration.yaml."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=base_config[DOMAIN],
)
if (
result["type"] is FlowResultType.CREATE_ENTRY
or result["reason"] == "already_configured"
):
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2024.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Genius Hub",
},
)
return
async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result['reason']}",
breaks_in_ha_version="2024.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Genius Hub",
},
)
async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool:
"""Set up a Genius Hub system."""
if DOMAIN in base_config:
hass.async_create_task(_async_import(hass, base_config))
return True
type GeniusHubConfigEntry = ConfigEntry[GeniusBroker]

View file

@ -13,7 +13,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
@ -123,14 +122,3 @@ class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="cloud_api", errors=errors, data_schema=CLOUD_API_SCHEMA
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import the yaml config."""
if CONF_HOST in import_data:
result = await self.async_step_local_api(import_data)
else:
result = await self.async_step_cloud_api(import_data)
if result["type"] is FlowResultType.FORM:
assert result["errors"]
return self.async_abort(reason=result["errors"]["base"])
return result

View file

@ -4,6 +4,7 @@ import logging
import shutil
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from awesomeversion import AwesomeVersion
from go2rtc_client import Go2RtcRestClient
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
from go2rtc_client.ws import (
@ -32,13 +33,23 @@ from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers import (
config_validation as cv,
discovery_flow,
issue_registry as ir,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.package import is_docker_env
from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL
from .const import (
CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE,
DOMAIN,
HA_MANAGED_URL,
RECOMMENDED_VERSION,
)
from .server import Server
_LOGGER = logging.getLogger(__name__)
@ -147,7 +158,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Validate the server URL
try:
client = Go2RtcRestClient(async_get_clientsession(hass), url)
await client.validate_server_version()
version = await client.validate_server_version()
if version < AwesomeVersion(RECOMMENDED_VERSION):
ir.async_create_issue(
hass,
DOMAIN,
"recommended_version",
is_fixable=False,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="recommended_version",
translation_placeholders={
"recommended_version": RECOMMENDED_VERSION,
"current_version": str(version),
},
)
except Go2RtcClientError as err:
if isinstance(err.__cause__, _RETRYABLE_ERRORS):
raise ConfigEntryNotReady(
@ -224,7 +249,13 @@ class WebRTCProvider(CameraWebRTCProvider):
):
await self._rest_client.streams.add(
camera.entity_id,
[stream_source, f"ffmpeg:{camera.entity_id}#audio=opus"],
[
stream_source,
# We are setting any ffmpeg rtsp related logs to debug
# Connection problems to the camera will be logged by the first stream
# Therefore setting it to debug will not hide any important logs
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
],
)
@callback

View file

@ -6,3 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
RECOMMENDED_VERSION = "1.9.7"

View file

@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/go2rtc",
"integration_type": "system",
"iot_class": "local_polling",
"requirements": ["go2rtc-client==0.1.0"],
"requirements": ["go2rtc-client==0.1.1"],
"single_config_entry": true
}

View file

@ -24,14 +24,15 @@ _RESPAWN_COOLDOWN = 1
# Default configuration for HA
# - Api is listening only on localhost
# - Disable rtsp listener
# - Enable rtsp for localhost only as ffmpeg needs it
# - Clear default ice servers
_GO2RTC_CONFIG_FORMAT = r"""
_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
# Do not edit it manually
api:
listen: "{api_ip}:{api_port}"
rtsp:
# ffmpeg needs rtsp for opus audio transcoding
listen: "127.0.0.1:18554"
webrtc:

View file

@ -0,0 +1,8 @@
{
"issues": {
"recommended_version": {
"title": "Outdated go2rtc server detected",
"description": "We detected that you are using an outdated go2rtc server version. For the best experience, we recommend updating the go2rtc server to version `{recommended_version}`.\nCurrently you are using version `{current_version}`."
}
}
}

View file

@ -87,8 +87,8 @@
}
},
"create_event": {
"name": "Creates event",
"description": "Add a new calendar event.",
"name": "Create event",
"description": "Adds a new calendar event.",
"fields": {
"summary": {
"name": "Summary",

View file

@ -78,6 +78,7 @@ TYPE_AWNING = f"{PREFIX_TYPES}AWNING"
TYPE_BLINDS = f"{PREFIX_TYPES}BLINDS"
TYPE_CAMERA = f"{PREFIX_TYPES}CAMERA"
TYPE_CURTAIN = f"{PREFIX_TYPES}CURTAIN"
TYPE_CARBON_MONOXIDE_DETECTOR = f"{PREFIX_TYPES}CARBON_MONOXIDE_DETECTOR"
TYPE_DEHUMIDIFIER = f"{PREFIX_TYPES}DEHUMIDIFIER"
TYPE_DOOR = f"{PREFIX_TYPES}DOOR"
TYPE_DOORBELL = f"{PREFIX_TYPES}DOORBELL"
@ -93,6 +94,7 @@ TYPE_SCENE = f"{PREFIX_TYPES}SCENE"
TYPE_SENSOR = f"{PREFIX_TYPES}SENSOR"
TYPE_SETTOP = f"{PREFIX_TYPES}SETTOP"
TYPE_SHUTTER = f"{PREFIX_TYPES}SHUTTER"
TYPE_SMOKE_DETECTOR = f"{PREFIX_TYPES}SMOKE_DETECTOR"
TYPE_SPEAKER = f"{PREFIX_TYPES}SPEAKER"
TYPE_SWITCH = f"{PREFIX_TYPES}SWITCH"
TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT"
@ -136,6 +138,7 @@ EVENT_SYNC_RECEIVED = "google_assistant_sync"
DOMAIN_TO_GOOGLE_TYPES = {
alarm_control_panel.DOMAIN: TYPE_ALARM,
binary_sensor.DOMAIN: TYPE_SENSOR,
button.DOMAIN: TYPE_SCENE,
camera.DOMAIN: TYPE_CAMERA,
climate.DOMAIN: TYPE_THERMOSTAT,
@ -168,6 +171,14 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = {
binary_sensor.DOMAIN,
binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR,
): TYPE_GARAGE,
(
binary_sensor.DOMAIN,
binary_sensor.BinarySensorDeviceClass.SMOKE,
): TYPE_SMOKE_DETECTOR,
(
binary_sensor.DOMAIN,
binary_sensor.BinarySensorDeviceClass.CO,
): TYPE_CARBON_MONOXIDE_DETECTOR,
(cover.DOMAIN, cover.CoverDeviceClass.AWNING): TYPE_AWNING,
(cover.DOMAIN, cover.CoverDeviceClass.CURTAIN): TYPE_CURTAIN,
(cover.DOMAIN, cover.CoverDeviceClass.DOOR): TYPE_DOOR,

View file

@ -2706,6 +2706,21 @@ class SensorStateTrait(_Trait):
),
}
binary_sensor_types = {
binary_sensor.BinarySensorDeviceClass.CO: (
"CarbonMonoxideLevel",
["carbon monoxide detected", "no carbon monoxide detected", "unknown"],
),
binary_sensor.BinarySensorDeviceClass.SMOKE: (
"SmokeLevel",
["smoke detected", "no smoke detected", "unknown"],
),
binary_sensor.BinarySensorDeviceClass.MOISTURE: (
"WaterLeak",
["leak", "no leak", "unknown"],
),
}
name = TRAIT_SENSOR_STATE
commands: list[str] = []
@ -2728,24 +2743,37 @@ class SensorStateTrait(_Trait):
@classmethod
def supported(cls, domain, features, device_class, _):
"""Test if state is supported."""
return domain == sensor.DOMAIN and device_class in cls.sensor_types
return (domain == sensor.DOMAIN and device_class in cls.sensor_types) or (
domain == binary_sensor.DOMAIN and device_class in cls.binary_sensor_types
)
def sync_attributes(self) -> dict[str, Any]:
"""Return attributes for a sync request."""
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
data = self.sensor_types.get(device_class)
if device_class is None or data is None:
return {}
def create_sensor_state(
name: str,
raw_value_unit: str | None = None,
available_states: list[str] | None = None,
) -> dict[str, Any]:
sensor_state: dict[str, Any] = {
"name": name,
}
if raw_value_unit:
sensor_state["numericCapabilities"] = {"rawValueUnit": raw_value_unit}
if available_states:
sensor_state["descriptiveCapabilities"] = {
"availableStates": available_states
}
return {"sensorStatesSupported": [sensor_state]}
sensor_state = {
"name": data[0],
"numericCapabilities": {"rawValueUnit": data[1]},
}
if device_class == sensor.SensorDeviceClass.AQI:
sensor_state["descriptiveCapabilities"] = {
"availableStates": [
if self.state.domain == sensor.DOMAIN:
sensor_data = self.sensor_types.get(device_class)
if device_class is None or sensor_data is None:
return {}
available_states: list[str] | None = None
if device_class == sensor.SensorDeviceClass.AQI:
available_states = [
"healthy",
"moderate",
"unhealthy for sensitive groups",
@ -2753,30 +2781,53 @@ class SensorStateTrait(_Trait):
"very unhealthy",
"hazardous",
"unknown",
],
}
return {"sensorStatesSupported": [sensor_state]}
]
return create_sensor_state(sensor_data[0], sensor_data[1], available_states)
binary_sensor_data = self.binary_sensor_types.get(device_class)
if device_class is None or binary_sensor_data is None:
return {}
return create_sensor_state(
binary_sensor_data[0], available_states=binary_sensor_data[1]
)
def query_attributes(self) -> dict[str, Any]:
"""Return the attributes of this trait for this entity."""
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
data = self.sensor_types.get(device_class)
if device_class is None or data is None:
def create_sensor_state(
name: str, raw_value: float | None = None, current_state: str | None = None
) -> dict[str, Any]:
sensor_state: dict[str, Any] = {
"name": name,
"rawValue": raw_value,
}
if current_state:
sensor_state["currentSensorState"] = current_state
return {"currentSensorStateData": [sensor_state]}
if self.state.domain == sensor.DOMAIN:
sensor_data = self.sensor_types.get(device_class)
if device_class is None or sensor_data is None:
return {}
try:
value = float(self.state.state)
except ValueError:
value = None
if self.state.state == STATE_UNKNOWN:
value = None
current_state: str | None = None
if device_class == sensor.SensorDeviceClass.AQI:
current_state = self._air_quality_description_for_aqi(value)
return create_sensor_state(sensor_data[0], value, current_state)
binary_sensor_data = self.binary_sensor_types.get(device_class)
if device_class is None or binary_sensor_data is None:
return {}
try:
value = float(self.state.state)
except ValueError:
value = None
if self.state.state == STATE_UNKNOWN:
value = None
sensor_data = {"name": data[0], "rawValue": value}
if device_class == sensor.SensorDeviceClass.AQI:
sensor_data["currentSensorState"] = self._air_quality_description_for_aqi(
value
)
return {"currentSensorStateData": [sensor_data]}
value = {
STATE_ON: 0,
STATE_OFF: 1,
STATE_UNKNOWN: 2,
}[self.state.state]
return create_sensor_state(
binary_sensor_data[0], current_state=binary_sensor_data[1][value]
)

View file

@ -25,7 +25,16 @@ UNIT_TASKS = "tasks"
ATTR_CONFIG_ENTRY = "config_entry"
ATTR_SKILL = "skill"
ATTR_TASK = "task"
ATTR_DIRECTION = "direction"
SERVICE_CAST_SKILL = "cast_skill"
SERVICE_START_QUEST = "start_quest"
SERVICE_ACCEPT_QUEST = "accept_quest"
SERVICE_CANCEL_QUEST = "cancel_quest"
SERVICE_ABORT_QUEST = "abort_quest"
SERVICE_REJECT_QUEST = "reject_quest"
SERVICE_LEAVE_QUEST = "leave_quest"
SERVICE_SCORE_HABIT = "score_habit"
SERVICE_SCORE_REWARD = "score_reward"
WARRIOR = "warrior"
ROGUE = "rogue"

View file

@ -51,12 +51,17 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
),
)
self.api = habitipy
self.content: dict[str, Any] = {}
async def _async_update_data(self) -> HabiticaData:
try:
user_response = await self.api.user.get()
tasks_response = await self.api.tasks.user.get()
tasks_response.extend(await self.api.tasks.user.get(type="completedTodos"))
if not self.content:
self.content = await self.api.content.get(
language=user_response["preferences"]["language"]
)
except ClientResponseError as error:
if error.status == HTTPStatus.TOO_MANY_REQUESTS:
_LOGGER.debug("Rate limit exceeded, will try again later")

View file

@ -126,6 +126,18 @@
},
"rewards": {
"default": "mdi:treasure-chest"
},
"strength": {
"default": "mdi:arm-flex-outline"
},
"intelligence": {
"default": "mdi:head-snowflake-outline"
},
"perception": {
"default": "mdi:eye-outline"
},
"constitution": {
"default": "mdi:run-fast"
}
},
"switch": {
@ -151,6 +163,30 @@
},
"cast_skill": {
"service": "mdi:creation-outline"
},
"accept_quest": {
"service": "mdi:script-text"
},
"reject_quest": {
"service": "mdi:script-text"
},
"leave_quest": {
"service": "mdi:script-text"
},
"abort_quest": {
"service": "mdi:script-text-key"
},
"cancel_quest": {
"service": "mdi:script-text-key"
},
"start_quest": {
"service": "mdi:script-text-key"
},
"score_habit": {
"service": "mdi:counter"
},
"score_reward": {
"service": "mdi:sack"
}
}
}

View file

@ -27,7 +27,7 @@ from homeassistant.helpers.typing import StateType
from .const import DOMAIN, UNIT_TASKS
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
from .util import entity_used_in
from .util import entity_used_in, get_attribute_points, get_attributes_total
_LOGGER = logging.getLogger(__name__)
@ -36,7 +36,10 @@ _LOGGER = logging.getLogger(__name__)
class HabitipySensorEntityDescription(SensorEntityDescription):
"""Habitipy Sensor Description."""
value_fn: Callable[[dict[str, Any]], StateType]
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
@dataclass(kw_only=True, frozen=True)
@ -65,76 +68,80 @@ class HabitipySensorEntity(StrEnum):
REWARDS = "rewards"
GEMS = "gems"
TRINKETS = "trinkets"
STRENGTH = "strength"
INTELLIGENCE = "intelligence"
CONSTITUTION = "constitution"
PERCEPTION = "perception"
SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
HabitipySensorEntityDescription(
key=HabitipySensorEntity.DISPLAY_NAME,
translation_key=HabitipySensorEntity.DISPLAY_NAME,
value_fn=lambda user: user.get("profile", {}).get("name"),
value_fn=lambda user, _: user.get("profile", {}).get("name"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.HEALTH,
translation_key=HabitipySensorEntity.HEALTH,
native_unit_of_measurement="HP",
suggested_display_precision=0,
value_fn=lambda user: user.get("stats", {}).get("hp"),
value_fn=lambda user, _: user.get("stats", {}).get("hp"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.HEALTH_MAX,
translation_key=HabitipySensorEntity.HEALTH_MAX,
native_unit_of_measurement="HP",
entity_registry_enabled_default=False,
value_fn=lambda user: user.get("stats", {}).get("maxHealth"),
value_fn=lambda user, _: user.get("stats", {}).get("maxHealth"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.MANA,
translation_key=HabitipySensorEntity.MANA,
native_unit_of_measurement="MP",
suggested_display_precision=0,
value_fn=lambda user: user.get("stats", {}).get("mp"),
value_fn=lambda user, _: user.get("stats", {}).get("mp"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.MANA_MAX,
translation_key=HabitipySensorEntity.MANA_MAX,
native_unit_of_measurement="MP",
value_fn=lambda user: user.get("stats", {}).get("maxMP"),
value_fn=lambda user, _: user.get("stats", {}).get("maxMP"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.EXPERIENCE,
translation_key=HabitipySensorEntity.EXPERIENCE,
native_unit_of_measurement="XP",
value_fn=lambda user: user.get("stats", {}).get("exp"),
value_fn=lambda user, _: user.get("stats", {}).get("exp"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.EXPERIENCE_MAX,
translation_key=HabitipySensorEntity.EXPERIENCE_MAX,
native_unit_of_measurement="XP",
value_fn=lambda user: user.get("stats", {}).get("toNextLevel"),
value_fn=lambda user, _: user.get("stats", {}).get("toNextLevel"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.LEVEL,
translation_key=HabitipySensorEntity.LEVEL,
value_fn=lambda user: user.get("stats", {}).get("lvl"),
value_fn=lambda user, _: user.get("stats", {}).get("lvl"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.GOLD,
translation_key=HabitipySensorEntity.GOLD,
native_unit_of_measurement="GP",
suggested_display_precision=2,
value_fn=lambda user: user.get("stats", {}).get("gp"),
value_fn=lambda user, _: user.get("stats", {}).get("gp"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.CLASS,
translation_key=HabitipySensorEntity.CLASS,
value_fn=lambda user: user.get("stats", {}).get("class"),
value_fn=lambda user, _: user.get("stats", {}).get("class"),
device_class=SensorDeviceClass.ENUM,
options=["warrior", "healer", "wizard", "rogue"],
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.GEMS,
translation_key=HabitipySensorEntity.GEMS,
value_fn=lambda user: user.get("balance", 0) * 4,
value_fn=lambda user, _: user.get("balance", 0) * 4,
suggested_display_precision=0,
native_unit_of_measurement="gems",
),
@ -142,7 +149,7 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
key=HabitipySensorEntity.TRINKETS,
translation_key=HabitipySensorEntity.TRINKETS,
value_fn=(
lambda user: user.get("purchased", {})
lambda user, _: user.get("purchased", {})
.get("plan", {})
.get("consecutive", {})
.get("trinkets", 0)
@ -150,6 +157,38 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
suggested_display_precision=0,
native_unit_of_measurement="",
),
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",
),
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",
),
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",
),
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,
native_unit_of_measurement="CON",
),
)
@ -243,7 +282,16 @@ class HabitipySensor(HabiticaBase, SensorEntity):
def native_value(self) -> StateType:
"""Return the state of the device."""
return self.entity_description.value_fn(self.coordinator.data.user)
return self.entity_description.value_fn(
self.coordinator.data.user, self.coordinator.content
)
@property
def extra_state_attributes(self) -> dict[str, float | None] | None:
"""Return entity specific state attributes."""
if func := self.entity_description.attributes_fn:
return func(self.coordinator.data.user, self.coordinator.content)
return None
class HabitipyTaskSensor(HabiticaBase, SensorEntity):

View file

@ -19,19 +19,29 @@ from homeassistant.core import (
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.selector import ConfigEntrySelector
from .const import (
ATTR_ARGS,
ATTR_CONFIG_ENTRY,
ATTR_DATA,
ATTR_DIRECTION,
ATTR_PATH,
ATTR_SKILL,
ATTR_TASK,
DOMAIN,
EVENT_API_CALL_SUCCESS,
SERVICE_ABORT_QUEST,
SERVICE_ACCEPT_QUEST,
SERVICE_API_CALL,
SERVICE_CANCEL_QUEST,
SERVICE_CAST_SKILL,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
SERVICE_SCORE_HABIT,
SERVICE_SCORE_REWARD,
SERVICE_START_QUEST,
)
from .types import HabiticaConfigEntry
@ -54,6 +64,19 @@ SERVICE_CAST_SKILL_SCHEMA = vol.Schema(
}
)
SERVICE_MANAGE_QUEST_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
}
)
SERVICE_SCORE_TASK_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
vol.Required(ATTR_TASK): cv.string,
vol.Optional(ATTR_DIRECTION): cv.string,
}
)
def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
"""Return config entry or raise if not found or not loaded."""
@ -70,10 +93,23 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
return entry
def async_setup_services(hass: HomeAssistant) -> None:
def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
"""Set up services for Habitica integration."""
async def handle_api_call(call: ServiceCall) -> None:
async_create_issue(
hass,
DOMAIN,
"deprecated_api_call",
breaks_in_ha_version="2025.6.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_api_call",
)
_LOGGER.warning(
"Deprecated action called: 'habitica.api_call' is deprecated and will be removed in Home Assistant version 2025.6.0"
)
name = call.data[ATTR_NAME]
path = call.data[ATTR_PATH]
entries = hass.config_entries.async_entries(DOMAIN)
@ -160,6 +196,104 @@ def async_setup_services(hass: HomeAssistant) -> None:
await coordinator.async_request_refresh()
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
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",
}
try:
return await coordinator.api.groups.party.quests[
COMMAND_MAP[call.service]
].post()
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.UNAUTHORIZED:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="quest_action_unallowed"
) from e
if e.status == HTTPStatus.NOT_FOUND:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="quest_not_found"
) from e
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="service_call_exception"
) from e
for service in (
SERVICE_ABORT_QUEST,
SERVICE_ACCEPT_QUEST,
SERVICE_CANCEL_QUEST,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
SERVICE_START_QUEST,
):
hass.services.async_register(
DOMAIN,
service,
manage_quests,
schema=SERVICE_MANAGE_QUEST_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
async def score_task(call: ServiceCall) -> ServiceResponse:
"""Score a task action."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
try:
task_id, task_value = next(
(task["id"], task.get("value"))
for task in coordinator.data.tasks
if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
or call.data[ATTR_TASK] == task["text"]
)
except StopIteration as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="task_not_found",
translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
) from e
try:
response: dict[str, Any] = (
await coordinator.api.tasks[task_id]
.score[call.data.get(ATTR_DIRECTION, "up")]
.post()
)
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.UNAUTHORIZED and task_value is not None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_enough_gold",
translation_placeholders={
"gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP",
"cost": f"{task_value} GP",
},
) from e
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
else:
await coordinator.async_request_refresh()
return response
hass.services.async_register(
DOMAIN,
SERVICE_API_CALL,
@ -174,3 +308,18 @@ def async_setup_services(hass: HomeAssistant) -> None:
schema=SERVICE_CAST_SKILL_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_SCORE_HABIT,
score_task,
schema=SERVICE_SCORE_TASK_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_SCORE_REWARD,
score_task,
schema=SERVICE_SCORE_TASK_SCHEMA,
supports_response=SupportsResponse.ONLY,
)

View file

@ -17,7 +17,7 @@ api_call:
object:
cast_skill:
fields:
config_entry:
config_entry: &config_entry
required: true
selector:
config_entry:
@ -33,7 +33,42 @@ cast_skill:
- "fireball"
mode: dropdown
translation_key: "skill_select"
task:
task: &task
required: true
selector:
text:
accept_quest:
fields:
config_entry: *config_entry
reject_quest:
fields:
config_entry: *config_entry
start_quest:
fields:
config_entry: *config_entry
cancel_quest:
fields:
config_entry: *config_entry
abort_quest:
fields:
config_entry: *config_entry
leave_quest:
fields:
config_entry: *config_entry
score_habit:
fields:
config_entry: *config_entry
task: *task
direction:
required: true
selector:
select:
options:
- value: up
label: ""
- value: down
label: ""
score_reward:
fields:
config_entry: *config_entry
task: *task

View file

@ -1,7 +1,8 @@
{
"common": {
"todos": "To-Do's",
"dailies": "Dailies"
"dailies": "Dailies",
"config_entry_name": "Select character"
},
"config": {
"abort": {
@ -164,6 +165,86 @@
},
"rewards": {
"name": "Rewards"
},
"strength": {
"name": "Strength",
"state_attributes": {
"level": {
"name": "[%key:component::habitica::entity::sensor::level::name%]"
},
"equipment": {
"name": "Battle gear"
},
"class": {
"name": "Class equip bonus"
},
"allocated": {
"name": "Allocated attribute points"
},
"buffs": {
"name": "Buffs"
}
}
},
"intelligence": {
"name": "Intelligence",
"state_attributes": {
"level": {
"name": "[%key:component::habitica::entity::sensor::level::name%]"
},
"equipment": {
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::equipment::name%]"
},
"class": {
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::class::name%]"
},
"allocated": {
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::allocated::name%]"
},
"buffs": {
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]"
}
}
},
"perception": {
"name": "Perception",
"state_attributes": {
"level": {
"name": "[%key:component::habitica::entity::sensor::level::name%]"
},
"equipment": {
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::equipment::name%]"
},
"class": {
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::class::name%]"
},
"allocated": {
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::allocated::name%]"
},
"buffs": {
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]"
}
}
},
"constitution": {
"name": "Constitution",
"state_attributes": {
"level": {
"name": "[%key:component::habitica::entity::sensor::level::name%]"
},
"equipment": {
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::equipment::name%]"
},
"class": {
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::class::name%]"
},
"allocated": {
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::allocated::name%]"
},
"buffs": {
"name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]"
}
}
}
},
"switch": {
@ -220,6 +301,9 @@
"not_enough_mana": {
"message": "Unable to cast skill, not enough mana. Your character has {mana}, but the skill costs {cost}."
},
"not_enough_gold": {
"message": "Unable to buy reward, not enough gold. Your character has {gold}, but the reward costs {cost}."
},
"skill_not_found": {
"message": "Unable to cast skill, your character does not have the skill or spell {skill}."
},
@ -230,13 +314,23 @@
"message": "The selected character is currently not loaded or disabled in Home Assistant."
},
"task_not_found": {
"message": "Unable to cast skill, could not find the task {task}"
"message": "Unable to complete action, could not find the task {task}"
},
"quest_action_unallowed": {
"message": "Action not allowed, only quest leader or group leader can perform this action"
},
"quest_not_found": {
"message": "Unable to complete action, quest or group not found"
}
},
"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."
}
},
"services": {
@ -263,7 +357,7 @@
"description": "Use a skill or spell from your Habitica character on a specific task to affect its progress or status.",
"fields": {
"config_entry": {
"name": "Select character",
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "Choose the Habitica character to cast the skill."
},
"skill": {
@ -275,6 +369,98 @@
"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": "Accept a pending invitation to a quest.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "Choose the Habitica character for which to perform the action."
}
}
},
"reject_quest": {
"name": "Reject a quest invitation",
"description": "Reject a pending invitation to a quest.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
}
}
},
"leave_quest": {
"name": "Leave a quest",
"description": "Leave the current quest you are participating in.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
}
}
},
"abort_quest": {
"name": "Abort an active quest",
"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%]",
"description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
}
}
},
"cancel_quest": {
"name": "Cancel a pending quest",
"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%]",
"description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
}
}
},
"start_quest": {
"name": "Force-start a pending quest",
"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%]",
"description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
}
}
},
"score_habit": {
"name": "Track a habit",
"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%]",
"description": "Select the Habitica character tracking your habit."
},
"task": {
"name": "Habit name",
"description": "The name (or task ID) of the Habitica habit."
},
"direction": {
"name": "Reward or loss",
"description": "Is it positive or negative progress you want to track for your habit."
}
}
},
"score_reward": {
"name": "Buy a reward",
"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%]",
"description": "Select the Habitica character buying the reward."
},
"task": {
"name": "Reward name",
"description": "The name (or task ID) of the custom reward."
}
}
}
},
"selector": {

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import datetime
from math import floor
from typing import TYPE_CHECKING, Any
from dateutil.rrule import (
@ -139,3 +140,52 @@ def get_recurrence_rule(recurrence: rrule) -> str:
"""
return str(recurrence).split("RRULE:")[1]
def get_attribute_points(
user: dict[str, Any], content: dict[str, Any], attribute: str
) -> dict[str, float]:
"""Get modifiers contributing to strength attribute."""
gear_set = {
"weapon",
"armor",
"head",
"shield",
"back",
"headAccessory",
"eyewear",
"body",
}
equipment = sum(
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(
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"]
)
return {
"level": min(round(user["stats"]["lvl"] / 2), 50),
"equipment": equipment,
"class": class_bonus,
"allocated": user["stats"][attribute],
"buffs": user["stats"]["buffs"][attribute],
}
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())
)

View file

@ -12,6 +12,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.components.script import scripts_with_entity
from homeassistant.config_entries import ConfigEntry
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,
@ -192,11 +193,32 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
await super().async_added_to_hass()
entity_automations = automations_with_entity(self.hass, self.entity_id)
entity_scripts = scripts_with_entity(self.hass, self.entity_id)
items = entity_automations + entity_scripts
automations = automations_with_entity(self.hass, self.entity_id)
scripts = scripts_with_entity(self.hass, self.entity_id)
items = automations + scripts
if not items:
return
entity_reg: er.EntityRegistry = er.async_get(self.hass)
entity_automations = [
automation_entity
for automation_id in automations
if (automation_entity := entity_reg.async_get(automation_id))
]
entity_scripts = [
script_entity
for script_id in scripts
if (script_entity := entity_reg.async_get(script_id))
]
items_list = [
f"- [{item.original_name}](/config/automation/edit/{item.unique_id})"
for item in entity_automations
] + [
f"- [{item.original_name}](/config/script/edit/{item.unique_id})"
for item in entity_scripts
]
async_create_issue(
self.hass,
DOMAIN,
@ -207,7 +229,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
translation_key="deprecated_binary_common_door_sensor",
translation_placeholders={
"entity": self.entity_id,
"items": "\n".join([f"- {item}" for item in items]),
"items": "\n".join(items_list),
},
)

View file

@ -24,7 +24,6 @@ from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
OptionsFlowWithConfigEntry,
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
@ -496,13 +495,15 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow):
return await self.async_step_pick_firmware()
class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlowWithConfigEntry):
class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
"""Zigbee and Thread options flow handlers."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
def __init__(self, config_entry: ConfigEntry, *args: Any, **kwargs: Any) -> None:
"""Instantiate options flow."""
super().__init__(*args, **kwargs)
self._config_entry = config_entry
self._probed_firmware_type = ApplicationType(self.config_entry.data["firmware"])
# Make `context` a regular dictionary

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