Compare commits

..

215 Commits

Author SHA1 Message Date
Allen Porter
27be570b69 Add tools to trace 2024-07-24 02:09:22 +00:00
Allen Porter
4879e02839 Modify ollama tool calls 2024-07-23 05:00:48 +00:00
Allen Porter
eaeca423d4 Update ollama tool calls 2024-07-23 05:00:48 +00:00
Allen Porter
8f688ee079 Ollama tool calling 2024-07-23 05:00:48 +00:00
Denis Shulyaka
975cfa6457 Fix gemini api format conversion (#122403)
* Fix gemini api format conversion

* add tests

* fix tests

* fix tests

* fix coverage
2024-07-22 17:56:13 -07:00
Erik Montnemery
5d3c57ecfe Freeze integration setup timeout for recorder during non-live migration (#122431) 2024-07-22 18:48:55 -05:00
Erik Montnemery
f4125eaf4c Remove loop shutdown indicator when done with test hass (#122432) 2024-07-23 00:56:06 +02:00
Erik Montnemery
96de0a4c94 Correct off-by-one bug in recorder non live schema migration (#122428)
* Correct off-by-one bug in recorder non live schema migration

* Remove change from the future
2024-07-23 00:30:31 +02:00
J. Nick Koston
d0ba5534cd Bump async-upnp-client to 0.40.0 (#122427) 2024-07-22 17:04:29 -05:00
Erik Montnemery
9b2118e556 Remove recorder from websocket_api after dependencies (#122422)
Co-authored-by: J. Nick Koston <nick@koston.org>
2024-07-22 16:50:05 -05:00
Erik Montnemery
42716723e6 Register WS command recorder/info early (#122425) 2024-07-22 16:26:52 -05:00
ribbal
ba276a5cb6 Add missing binary sensors to Hive integration (#122296)
* add missing sensors

* add missing sensors

* add missing sensors

* add missing sensors

* add missing sensors

* add missing sensors

* add missing sensors

* add missing sensors

* add missing sensors

* add missing sensors

* add missing sensors

* add missing sensors
2024-07-22 23:15:36 +02:00
Erik Montnemery
3df6b34a03 Split recorder and frontend bootstrap steps (#122420) 2024-07-22 23:07:49 +02:00
Denis Shulyaka
ee30510b23 Remove deprecated DALL-E image formats (#122388) 2024-07-22 21:57:48 +02:00
Alexandre CUER
489457c47b Add async_update_data to emoncms coordinator (#122416)
* Add async_update_data to coordinator

* Add const module
2024-07-22 21:47:01 +02:00
Joakim Plate
a1cdd91d23 Continue transition from legacy dict to attr in dsmr (#121906) 2024-07-22 21:41:24 +02:00
Mr. Bubbles
fed17a4905 Add DeviceInfo to OTP integration (#122392) 2024-07-22 21:39:22 +02:00
Robert Svensson
c61efe931a Deduplicate more fixture data related to deCONZ websocket sensor (#122412) 2024-07-22 21:37:58 +02:00
Erik Montnemery
d3df903d1e Make device registry migration unconditional (#122414) 2024-07-22 21:37:47 +02:00
Erik Montnemery
db6704271c Avoid repeated calls to utc_from_timestamp(0).isoformat() when migrating (#122413) 2024-07-22 21:36:36 +02:00
Erik Montnemery
3dc36cf068 Improve error handling when creating new SQLite database (#122406)
Co-authored-by: J. Nick Koston <nick@koston.org>
2024-07-22 14:16:11 -05:00
Erik Montnemery
20fc5233a1 Add recorder data migrator class to clean up states table (#122069) 2024-07-22 13:04:01 -05:00
Robert Resch
4c853803f1 Add created_at/modified_at to device registry (#122369) 2024-07-22 19:15:23 +02:00
Noah Husby
19d9a91392 Add device info to Russound RIO (#122395)
* Add device info to Russound RIO

* Set device info name to Russound model

* Add device class to Russound media player

* Move device info to constructor

* Use connections instead of identifiers for russound

* Add via_device attr to Russound

* Reinstate russound identifiers

* Move has entity name attr
2024-07-22 19:06:13 +02:00
Erik Montnemery
76cd53a864 Improve error handling when recorder schema migration fails (#122397) 2024-07-22 18:55:12 +02:00
Noah Husby
02c34ba3f8 Bump aiorussound to 2.0.7 (#122389) 2024-07-22 18:01:54 +02:00
Erik Montnemery
b14e8d1609 Remove SchemaValidationStatus.valid (#122394) 2024-07-22 17:33:13 +02:00
Erik Montnemery
e8b88557ee Refactor recorder schema migration (#122372)
* Refactor recorder schema migration

* Simplify

* Remove unused imports

* Refactor _migrate_schema according to review comments

* Add comment
2024-07-22 16:53:54 +02:00
Erik Montnemery
c73e7ae178 Handle integration with missing dependencies (#122386) 2024-07-22 15:41:55 +02:00
Joost Lekkerkerker
7ec41275d5 Add mealie service to set mealplan (#122317) 2024-07-22 15:34:10 +02:00
Erik Montnemery
debebcfd25 Improve language in loader error messages (#122387) 2024-07-22 15:32:36 +02:00
Pete Sage
186ca49b16 Fix group media player play_media not passing kwargs (#122258) 2024-07-22 14:30:23 +02:00
Robert Resch
243a68fb1f Frontend wants a timestamp for the created_at/modified_at attributes (#122377) 2024-07-22 14:10:16 +02:00
J. Nick Koston
bd97a09cae Complete coverage for doorbird init (#122272) 2024-07-22 13:57:43 +02:00
J. Nick Koston
d421525f1b Fix missing translation key for august doorbells (#122251) 2024-07-22 13:15:43 +02:00
Allen Porter
7ec332f857 Fix platforms on media pause and unpause intents (#122357) 2024-07-22 13:15:05 +02:00
Brett Adams
31d3b3b675 Handle empty energy sites in Tesla integrations (#122355) 2024-07-22 13:14:15 +02:00
cdnninja
ea94cdb668 Bump pyvesync to 2.1.12 (#122318) 2024-07-22 13:09:08 +02:00
Erik Montnemery
cbe94c4706 Fix typo in recorder persistent notification (#122374) 2024-07-22 12:02:17 +02:00
Duco Sebel
5612e3a92b Bumb python-homewizard-energy to 6.1.1 to embed model in upstream library (#122365) 2024-07-22 11:26:38 +02:00
Paul Bottein
33f0840a26 Add translations for xiaomi miio fan preset modes (#122367) 2024-07-22 11:21:54 +02:00
starkillerOG
8d538fcd52 Add Reolink model_id / item number (#122371) 2024-07-22 11:20:02 +02:00
Marc Mueller
9793aa0a5e Update pytest to 8.3.1 (#122368) 2024-07-22 11:16:05 +02:00
Denis Shulyaka
064d7261b4 Ensure script llm tool name does not start with a digit (#122349)
* Ensure script tool name does not start with a digit

* Fix test name
2024-07-22 11:11:09 +02:00
starkillerOG
0c6dc9e43b Bump reolink-aio to 0.9.5 (#122366) 2024-07-22 11:09:03 +02:00
Marc Mueller
c70e611822 Fix homewizard api close not being awaited on unload (#122324) 2024-07-22 10:19:08 +02:00
J. Nick Koston
02c64c7861 Bump cryptography to 43.0.0 and pyOpenSSL to 24.2.1 and chacha20poly1305-reuseable >= 0.13.0 (#122308) 2024-07-22 10:15:02 +02:00
dependabot[bot]
5b32efb6d6 Bump github/codeql-action from 3.25.12 to 3.25.13 (#122362)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 10:05:15 +02:00
Matthias Alphart
34e72ea16a Add support for KNX UI to create light entities (#122342)
* Add light to KNX UI-createable entity platforms

* review from switch

* Add a test
2024-07-22 09:35:29 +02:00
Noah Husby
f30c6e01f9 Bump aiorussound to 2.0.6 (#122354)
bump aiorussound to 2.0.6
2024-07-22 08:56:48 +02:00
J. Nick Koston
db9fc27a5c Convert enphase_envoy to use entry.runtime_data (#122345) 2024-07-22 07:44:00 +02:00
Denis Shulyaka
ac1ad9680b Goofle Generative AI: Fix string format (#122348)
* Ignore format for string tool args

* Add tests
2024-07-21 21:54:31 -07:00
Marc Mueller
4eb096cb0a Update pylint to 3.2.6 (#122338) 2024-07-22 01:44:52 +02:00
Maciej Bieniek
bc5849e4ef Bump aiotractive to 0.6.0 (#121155)
Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2024-07-21 18:11:05 -05:00
Alexander Schneider
9a3c7111f7 Add Z-Wave discovery schema for ZVIDAR roller shades (#122332)
Add discovery schema for ZVIDAR roller shades
2024-07-21 23:51:10 +02:00
Allen Porter
c98c80ce69 Change OpenAI default recommended model to gpt-4o-mini (#122333) 2024-07-21 13:37:18 -07:00
Lorzware
453848bcdc APSystems - add configuration option 'port' in config flow (#122144)
* Add configuration option 'port' in config flow
2024-07-21 22:03:41 +02:00
Joost Lekkerkerker
7d46890804 Add support for grouping notify entities (#122123)
* Add support for grouping notify entities

* Add support for grouping notify entities

* Add support for grouping notify entities

* Fix test

* Fix feedback

* Update homeassistant/components/group/notify.py

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

* Test config flow changes

* Test config flow changes

---------

Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2024-07-21 20:57:49 +02:00
Michael
7e1fb88e4e Post merge review for Feedreader (#122327)
* remove unneccessary typing

* assert type list while type checking

* remove summary, since feedparser parse it already into content

* add further tests
2024-07-21 20:55:02 +02:00
epenet
94ce02f406 Bump renault-api to 2.0.5 (#122326)
* Bump renault-api to 2.0.5

* Update requirements_all.txt

* Update requirements_test_all.txt
2024-07-21 13:18:43 -05:00
Matthias Alphart
d418a40856 Create, update and delete KNX entities from UI / WS-commands (#104079)
* knx entity CRUD - initial commit - switch

* platform dependent schema

* coerce empty GA-lists to None

* read entity configuration from WS

* use entity_id instead of unique_id for lookup

* Add device support

* Rename KNXEntityStore to KNXConfigStore

* fix test after rename

* Send schema options for creating / editing entities

* Return entity_id after entity creation

* remove device_class config in favour of more-info-dialog settings

* refactor group address schema for custom selector

* Rename GA keys and remove invalid keys from schema

* fix rebase

* Fix deleting devices and their entities

* Validate entity schema in extra step - return validation infos

* Use exception to signal validation error; return validated data

* Forward validation result when editing entities

* Get proper validation error message for optional GAs

* Add entity validation only WS command

* use ulid instead of uuid

* Fix error handling for edit unknown entity

* Remove unused optional group address sets from validated schema

* Add optional dpt field for ga_schema

* Move knx config things to sub-key

* Add light platform

* async_forward_entry_setups only once

* Test crate and remove devices

* Test removing entities of a removed device

* Test entity creation and storage

* Test deleting entities

* Test unsuccessful entity creation

* Test updating entity data

* Test get entity config

* Test validate entity

* Update entity data by entity_id instead of unique_id

* Remove unnecessary uid unique check

* remove schema_options

* test fixture for entity creation

* clean up group address schema

class can be used to add custom serializer later

* Revert: Add light platfrom

* remove unused optional_ga_schema

* Test GASelector

* lint tests

* Review

* group entities before adding

* fix / ignore mypy

* always has_entity_name

* Entity name: check for empty string when no device

* use constants instead of strings in schema

* Fix mypy errors for voluptuous schemas

---------

Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
2024-07-21 20:01:48 +02:00
GeoffAtHome
890b54e36f Add config flow to Genius hub (#116173)
* Adding config flow

* Fix setup issues.

* Added test for config_flow

* Refactor schemas.

* Fixed ruff-format on const.py

* Added geniushub-cleint to requirements_test_all.txt

* Updates from review.

* Correct multiple logger comment errors.

* User menu rather than check box.

* Correct logger messages.

* Correct test_config_flow

* Import config entry from YAML

* Config flow integration

* Refactor genius hub test_config_flow.

* Improvements and simplification from code review.

* Correct tests

* Stop device being added twice.

* Correct validate_input.

* Changes to meet code review three week ago.

* Fix Ruff undefined error

* Update homeassistant/components/geniushub/config_flow.py

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

* Update homeassistant/components/geniushub/config_flow.py

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

* Change case Cloud and Local to CLOUD and LOCAL.

* More from code review

* Fix

* Fix

* Update homeassistant/components/geniushub/strings.json

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-07-21 19:57:41 +02:00
Marc Mueller
6de824e875 Fix test RuntimeWarning for upb (#122325) 2024-07-21 18:50:00 +02:00
Joost Lekkerkerker
273dc0998f Clean up Mealie service tests (#122316) 2024-07-21 10:15:28 -05:00
J. Nick Koston
5f4dedb4a8 Add binary sensor platform to govee-ble (#122111) 2024-07-21 09:47:59 -05:00
Joost Lekkerkerker
6f4a8a4a14 Add Mealie service to set a random mealplan (#122313)
* Add Mealie service to set a random mealplan

* Fix coverage

* Fix coverage
2024-07-21 16:43:46 +02:00
J. Nick Koston
39068bb786 Add sleepy device support to govee-ble (#122085) 2024-07-21 09:38:00 -05:00
J. Nick Koston
7e82b3ecdb Add event platform to govee-ble (#122031)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-07-21 09:32:58 -05:00
Joost Lekkerkerker
8d01ad98eb Clean up Mealie coordinator (#122310)
* Clean up Mealie coordinator

* Clean up Mealie coordinator

* Clean up Mealie coordinator

* Fix

* Fix
2024-07-21 16:24:46 +02:00
Joost Lekkerkerker
a78d6b8c36 Set polling interval for airgradient to 1 minute (#122266) 2024-07-21 16:22:45 +02:00
Joost Lekkerkerker
e8796cd725 Improve Hive typing (#122314) 2024-07-21 16:21:45 +02:00
J. Nick Koston
b0a4140b4d Convert sensorpush to use entry.runtime_data (#122315) 2024-07-21 09:11:18 -05:00
J. Nick Koston
30373a668c Migrate harmony to use entry.runtime_data (#122312) 2024-07-21 09:06:51 -05:00
J. Nick Koston
272f0bc21c Migrate oncue to use entry.runtime_data (#122307) 2024-07-21 08:19:58 -05:00
J. Nick Koston
7f852d0f73 Update bthome to use entry.runtime_data (#122304) 2024-07-21 08:19:46 -05:00
J. Nick Koston
8994c18f73 Update xiaomi-ble to use entry.runtime_data (#122306) 2024-07-21 08:19:33 -05:00
Joost Lekkerkerker
874b1ae873 Add sensor platform to Mealie (#122280)
* Bump aiomealie to 0.7.0

* Add sensor platform to Mealie

* Fix
2024-07-21 14:59:22 +02:00
Joost Lekkerkerker
7f82fb8cb8 Bump aiomealie to 0.8.0 (#122295)
* Bump aiomealie to 0.8.0

* Bump aiomealie to 0.8.0
2024-07-21 14:52:18 +02:00
J. Nick Koston
a8cbfe5159 Make TemplateStateBase.entity_id a cached_property (#122279) 2024-07-21 07:49:59 -05:00
Marcel Vriend
0ab1ccc5ae Fix to prevent Azure Data Explorer JSON serialization from failing (#122300) 2024-07-21 14:08:58 +02:00
Robert Svensson
48661054d9 Improve fixture usage for sensor based deCONZ tests (#122297) 2024-07-21 13:56:16 +02:00
Jan Bouwhuis
87e377cf84 Ensure mqtt subscriptions are in a set (#122201) 2024-07-21 12:36:06 +02:00
Arie Catsman
8da630f8c6 Improve sensor test coverage for enphase_envoy (#122229)
* Improve sensor platform test COV for enphase_envoy

* Use async_fire_time_changed to trigger next data update in enphase_envoy test
2024-07-21 12:26:32 +02:00
Louis Christ
f629364dc4 Use pyblu library in bluesound (#117257)
* Integrate pypi libraray: pyblu

* Raise PlatformNotReady if _sync_status is not available yet

* Revert "Raise PlatformNotReady if _sync_status is not available yet"

This reverts commit a649a6bccd00cf16f80e40dc169ca8797ed3b6b2.

* Replace 'async with timeout' with parameter in library

* Set timeout back to 10 seconds

* ruff fixes

* Update homeassistant/components/bluesound/media_player.py

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

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-07-21 12:24:54 +02:00
ilan
fcca475e36 Add sensor platform to MadVR (#121617)
* feat: add sensors

* feat: add tests for sensors

* feat: add options flow

* feat: add tests for options flow

* fix: remove options flow

* fix: remove names and mac sensor, add incoming signal type

* feat: add enum types to supported sensors

* fix: consolidate tests into snapshot

* fix: use consts

* fix: update names and use snapshot platform

* fix: fix test name for new translations

* fix: comment

* fix: improve sensor names

* fix: address comments

* feat: disable uncommon sensors by default

* fix: update sensors

* fix: revert config_flow change
2024-07-21 08:43:52 +02:00
Sid
5075f0aac8 Bump ruff to 0.5.4 (#122289) 2024-07-21 08:42:06 +02:00
Joost Lekkerkerker
537a76d049 Add model id to airgradient (#122271) 2024-07-21 08:41:42 +02:00
J. Nick Koston
b3698a59e1 Bump uiprotect to 5.4.0 (#122282) 2024-07-20 17:24:16 -05:00
Joost Lekkerkerker
9b9db86f1c Bump aiomealie to 0.7.0 (#122278) 2024-07-21 00:00:31 +02:00
ilan
1e28ae49f9 Bump py-madvr to 1.6.29 (#122275)
chore: bump version
2024-07-20 22:44:14 +02:00
Joost Lekkerkerker
24b6f71f94 Bump twitchAPI to 4.2.1 (#122269) 2024-07-20 21:29:51 +02:00
Joost Lekkerkerker
ae4360b0e5 Bump airgradient to 0.7.0 (#122268) 2024-07-20 21:26:00 +02:00
Marc Mueller
769d7214a3 Improve tests.common typing (#122257) 2024-07-20 17:34:43 +02:00
Marc Mueller
90e7d82049 Use correct enum in UnitSystem tests (#122256) 2024-07-20 17:33:48 +02:00
Marc Mueller
5e8b022246 Improve shopping_list test typing (#122255) 2024-07-20 16:46:39 +02:00
J. Nick Koston
43aeaf7a9b Upgrade CI to use ubuntu 24.04 (#122254) 2024-07-20 09:43:10 -05:00
Brian Rogers
63b0feeae7 Add calendar for Rachio smart hose timer (#120030) 2024-07-20 09:38:51 -05:00
Brett Adams
0f079454bb Add device tracker to Tesla Fleet (#122222) 2024-07-20 14:37:57 +02:00
Marc Mueller
6be4ef8a1f Improve contextmanager typing (#122250) 2024-07-20 14:09:37 +02:00
Marc Mueller
5fd3b929f4 Update types packages (#122245) 2024-07-20 14:09:10 +02:00
Marc Mueller
55abbc51a4 Update pip-licenses to 4.5.1 (#122240) 2024-07-20 13:52:55 +02:00
Marc Mueller
651fb95010 Update uv to 0.2.27 (#122246) 2024-07-20 13:24:21 +02:00
Marc Mueller
c6713edc8b Update pytest-unordered to 0.6.1 (#122243) 2024-07-20 13:24:01 +02:00
Marc Mueller
ee49c57e95 Update pytest to 8.2.2 (#122244) 2024-07-20 13:16:36 +02:00
Erik Montnemery
2f47312eeb Fix recorder setup hanging if non live schema migration fails (#122242) 2024-07-20 13:10:23 +02:00
Marc Mueller
293ad99dae Update pytest-asyncio to 0.23.8 (#122241) 2024-07-20 13:10:09 +02:00
Marc Mueller
0fe7aa1a43 Update bcrypt to 4.1.3 (#122236) 2024-07-20 13:06:22 +02:00
Marc Mueller
b54b08479d Update pipdeptree to 2.23.1 (#122239) 2024-07-20 12:59:44 +02:00
Marc Mueller
ab2f38216d Update coverage to 7.6.0 (#122238) 2024-07-20 12:59:08 +02:00
Marc Mueller
13da20ddf4 Update Pillow to 10.4.0 (#122237) 2024-07-20 12:58:49 +02:00
Erik Montnemery
436a38c1d2 Revert "Fix recorder setup hanging if non live schema migration fails" (#122232) 2024-07-20 12:29:08 +02:00
Brett Adams
2b93de1348 Add binary sensor to Tesla Fleet (#122225) 2024-07-20 11:28:30 +02:00
Robert Svensson
ecffae0b4f Improve fixture usage for light based deCONZ tests (#122209) 2024-07-20 11:25:00 +02:00
Brett Adams
6f9e39cd3f Add diagnostics to Tesla Fleet (#122223) 2024-07-20 11:22:15 +02:00
Arie Catsman
221480add1 Improve switch platform test COV for enphase_envoy (#122227) 2024-07-20 11:20:46 +02:00
Erik Montnemery
153b69c971 Fix recorder setup hanging if non live schema migration fails (#122207) 2024-07-20 11:17:40 +02:00
Pete Sage
d1d2ce1270 Sonos tests snapshot and restore services (#122198) 2024-07-20 11:16:48 +02:00
Marc Mueller
a6068dcdf2 Update import locations in tests (#122216) 2024-07-20 11:16:04 +02:00
Marc Mueller
0637e342f6 Fix ConfigFlowResult annotations in tests (#122215) 2024-07-20 11:13:13 +02:00
Marc Mueller
e9f5c4188e Fix incompatible signature overwrite async_turn_on + off (#122208) 2024-07-20 11:12:41 +02:00
Marc Mueller
24b12bc509 Improve HA snapshot serializer typing (#122218) 2024-07-20 11:12:02 +02:00
Marc Mueller
f0b9a806d1 Fix missing type[..] annotation in tests (#122217) 2024-07-20 11:11:16 +02:00
Marc Mueller
f8c4ffc060 Update freezegun to 1.5.1 (#122219) 2024-07-20 11:10:46 +02:00
Marc Mueller
768d20c645 Fix recorder datetime annotations (#122214) 2024-07-20 11:10:25 +02:00
Erik Montnemery
a0332d049b Fix flaky recorder test (#122205) 2024-07-20 11:09:52 +02:00
Álvaro Fernández Rojas
4ee2c445d1 Update home_connect to v0.8.0 (#121788)
Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>
2024-07-20 05:28:04 +02:00
Marc Mueller
458c81cdae Improve vizio tests typing (#122213) 2024-07-20 02:50:12 +02:00
Marc Mueller
8e024ad20f Fix invalid Any annotations (#122212) 2024-07-20 02:46:27 +02:00
Mr. Bubbles
a46fffd550 Fix wrong deprecation date in Habitica integration (#122206)
Fix wrong deprecation date
2024-07-20 00:40:29 +02:00
G Johansson
288faf48e7 Add config flow to Wake on LAN (#121605) 2024-07-19 21:20:43 +02:00
HarvsG
7e0970e917 Log timeouts for assist_pipeline end of speech detection (#122182)
* log timeouts

* import logger the right way
2024-07-19 13:43:38 -05:00
Sean Chen
e6e748ae0a Add timestamp sensor for observation (#121752) 2024-07-19 19:50:38 +02:00
dougiteixeira
75b1700ed3 Move constants to const.py in generic Thermostat (#120789) 2024-07-19 19:49:11 +02:00
Steven B.
099110767a Add tests for ring camera platform for 100% coverage (#122197) 2024-07-19 19:35:44 +02:00
Pierre Mavro
cafff3eddf Add PrusaLink nozzle and mmu support (#120436)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2024-07-19 19:15:42 +02:00
Steven Looman
c0732fbb1d Add options flow for force_poll setting in upnp (#120843) 2024-07-19 19:02:28 +02:00
Stefan Agner
7b5b6c7b85 Tolerate integration removed device (#120722) 2024-07-19 19:00:31 +02:00
Jan Rieger
12ec66c2c2 Avoid blocking I/O in gpsd (#122176) 2024-07-19 18:25:07 +02:00
Mr. Bubbles
72d37036b9 Remove filtering of user data in Habitica integration (#121759)
Remove context-based userFields filtering
2024-07-19 17:56:52 +02:00
Sid
e029dad0eb Add data update coordinator to enigma2 (#122046)
* Add data update coordinator to enigma2

* Apply suggestions from code review

Co-authored-by: Robert Resch <robert@resch.dev>

---------

Co-authored-by: Robert Resch <robert@resch.dev>
2024-07-19 17:51:34 +02:00
G Johansson
0cde518a89 Allow to add optional holiday categories in workday (#121396)
* Allow to add optional holiday categories in workday

* Add tests

* Fix coverage
2024-07-19 17:49:27 +02:00
Christopher Fenner
dab66990c0 Ignore E3_TCU41_x04 gateway device in ViCare (#122179)
skip gateway device
2024-07-19 11:40:17 -04:00
Steven Looman
d3029af888 Address post merge review changes in upnp (#122189)
Post merge review change
2024-07-19 11:38:38 -04:00
Marcel Vriend
419bf0165a Bump azure-kusto dependencies to 4.5.1 (#121805) 2024-07-19 17:33:02 +02:00
Shai Ungar
978ee918cb Use new 17track api library (#121910) 2024-07-19 17:09:50 +02:00
Marc Mueller
8bca9a3449 Fix return type annotations in tests (#122184) 2024-07-19 16:44:03 +02:00
Malte Franken
87ecf5d85e Bump georss-qld-bushfire-alert-client to 0.8 (#122185) 2024-07-19 16:12:26 +02:00
Marc Mueller
2f8dfb424b Use Generator as return type for fixtures (#122183) 2024-07-19 14:55:23 +02:00
Marc Mueller
53c85a5c9b Fix test fixture annotations (#122180) 2024-07-19 14:46:30 +02:00
Marc Mueller
281c66b6c2 Fix invalid dict annotations in tests (#122178) 2024-07-19 14:45:42 +02:00
Bram Kragten
6788c43775 Add static routes for frontend modern and legacy service workers (#120488) (#122174)
* Bump frontend to 20240719.0

* restore #120488
2024-07-19 14:40:50 +02:00
Josef Zweck
f006716173 Add async_setup method to DataUpdateCoordinator (#116677)
* init

* Update homeassistant/helpers/update_coordinator.py

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

* fix typo, ruff

* consistency with rest, test

* pylint suppression

* ruff

* ruff

* switch to one test

* add last exc

* add tests for auth & Entry Errors

* move exceptions to correct test

* Update update_coordinator.py

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

* test setup call

* simplify

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2024-07-19 14:24:25 +02:00
dougiteixeira
de5b5f6d36 Add string for value template description in switch Template (#121865)
Add string for value template descrition in switch Template
2024-07-19 13:58:22 +02:00
Harry Martland
e9ea90dc82 Fix hive not updating when boosting (#122042)
* fixes issue where hive does not update when boosting

* formats files
2024-07-19 13:47:28 +02:00
Marc Mueller
0be68dcd7f Fix deconz conftest typing (#122173) 2024-07-19 13:10:38 +02:00
Marc Mueller
c92d9dcb74 Use TypeVar defaults for Generator (#122170) 2024-07-19 13:06:45 +02:00
Marc Mueller
2b486c3bd5 Replace unnecessary typing_extensions imports for Generator (#122169) 2024-07-19 12:56:27 +02:00
Bram Kragten
c28a138dfe Revert "Add static routes for frontend modern and legacy service workers" (#122172) 2024-07-19 12:55:40 +02:00
Mr. Bubbles
3ddcffb7b7 Fix reauth error and exception in ista EcoTrend integration (#121482) 2024-07-19 12:26:40 +02:00
Matrix
8cb7e9785f Add YoLink YS8017 support (#122064) 2024-07-19 12:20:30 +02:00
Jan Bouwhuis
16434b5306 Add command_template option to mqtt switch schema (#122103) 2024-07-19 12:10:49 +02:00
Paolo Burgio
c1c5cff993 Add integration for iotty Smart Home (#103073)
* Initial import 0.0.2

* Fixes to URL, and removed commits

* Initial import 0.0.2

* Fixes to URL, and removed commits

* Added first test for iotty

* First release

* Reviewers request #1
- Removed clutter
- Added support for new naming convention for IottySmartSwitch entity

* Removed commmented code

* Some modifications

* Modified REST EP for iotty CloudApi

* Initial import 0.0.2

* Fixes to URL, and removed commits

* Added first test for iotty

* First release

* Rebased and resolved conflicts

* Reviewers request #1
- Removed clutter
- Added support for new naming convention for IottySmartSwitch entity

* Removed commmented code

* Some modifications

* Modified REST EP for iotty CloudApi

* Removed empty entries in manifest.json

* Added test_config_flow

* Fix as requested by @edenhaus

* Added test_init

* Removed comments, added one assert

* Added TEST_CONFIG_FLOW

* Added test for STORE_ENTITY

* Increased code coverage

* Full coverage for api.py

* Added tests for switch component

* Converted INFO logs onto DEBUG logs

* Removed .gitignore from commits

* Modifications to SWITCH.PY

* Initial import 0.0.2

* Fixes to URL, and removed commits

* Added first test for iotty

* First release

* Rebased and resolved conflicts

* Fixed conflicts

* Reviewers request #1
- Removed clutter
- Added support for new naming convention for IottySmartSwitch entity

* Removed commmented code

* Some modifications

* Modified REST EP for iotty CloudApi

* Removed empty entries in manifest.json

* Added test_config_flow

* Some modifications

* Fix as requested by @edenhaus

* Added test_init

* Removed comments, added one assert

* Added TEST_CONFIG_FLOW

* Added test for STORE_ENTITY

* Increased code coverage

* Full coverage for api.py

* Added tests for switch component

* Converted INFO logs onto DEBUG logs

* Removed .gitignore from commits

* Modifications to SWITCH.PY

* Fixed tests for SWITCH

* First working implementation of Coordinator

* Increased code coverage

* Full code coverage

* Missing a line in testing

* Update homeassistant/components/iotty/__init__.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/iotty/__init__.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Modified coordinator as per request by edenhaus

* use coordinator entities for switches

* move platforms to constants

* fix whitespace with ruff-format

* correct iotty entry in application_credentials list

* minor style improvements

* refactor function name

* handle new and deleted devices

* improve code for adding devices after first initialization

* use typed config entry instead of adding known devices to hass.data

* improve iotty entity removal

* test listeners update cycle

* handle iotty as devices and not only as entities

* fix test typing for mock config entry

* test with fewer mocks for an integration test style opposed to the previous unit test style

* remove useless tests and add more integration style tests

* check if device_to_remove is None

* integration style tests for turning switches on and off

* remove redundant coordinator tests

* check device status after issuing command in tests

* remove unused fixtures

* add strict typing for iotty

* additional asserts and named snapshots in tests

* fix mypy issues after enabling strict typing

* upgrade iottycloud version to 0.1.3

* move coordinator to runtime_data

* remove entity name

* fix typing issues

* coding style fixes

* improve tests coding style and assertion targets

* test edge cases when apis are not working

* improve tests comments and assertions

---------

Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Shapour Nemati <shapour.nemati@iotty.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: shapournemati-iotty <130070037+shapournemati-iotty@users.noreply.github.com>
2024-07-19 12:10:39 +02:00
Sid
4620a54582 Bump Ruff to 0.5.3 (#122167) 2024-07-19 12:06:53 +02:00
Arie Catsman
0b691f9393 Improve number platform test COV for enphase_envoy (#122163)
* Improve number platform COV for enphase_envoy

* remove use of rand in pytest_number of enphase_envoy
2024-07-19 11:47:40 +02:00
Franck Nijhof
bcf4c73f32 Migrate Wiz to config entry runtime data (#122091) 2024-07-19 11:36:59 +02:00
G Johansson
ca4c617d4b Add TURN_OFF/TURN_ON feature flags for fan (#121447) 2024-07-19 11:35:24 +02:00
Åke Strandberg
172778053c Add select platform to myuplink (#118661) 2024-07-19 11:29:58 +02:00
G Johansson
f5f9480b5a Deprecate simulated integration (#122166) 2024-07-19 11:26:05 +02:00
Steve Repsher
e50802aca3 Add static routes for frontend modern and legacy service workers (#120488) 2024-07-19 10:53:37 +02:00
Jeef
de18be235d Add Sensors to Weatherflow Cloud (#111651)
* continue

* Rebase dev

* signle function to generate attr_entity info

* rewrite icon determination as an if block

* handling PR

* Removing wind sensors for now - separate future PR

* ruff

* Update coordinator.py

Thought i already did this

* Update sensor.py

* Update icons.json

* Update sensor.py

* Update homeassistant/components/weatherflow_cloud/entity.py

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

* working on a unified entity

* working on a unified entity

* sensor refactor

* addressing entity comment

* Update homeassistant/components/weatherflow_cloud/entity.py

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

* Update homeassistant/components/weatherflow_cloud/sensor.py

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

* doc

* pr comments again

* fixing PR

* fixing PR

* applying entity class in sensor

* Update homeassistant/components/weatherflow_cloud/sensor.py

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

* Cleaning up weather.py

* station id cleanup for weather class

* rewrite adding sensors the correct way

* Adding snapshot testing

* snapshot update

* added total class

* updated snapshots

* minor tweak

* snapshot away

* adding more coverage

* switch back to total

* Apply suggestions from code review

* Apply suggestions from code review

* Apply suggestions from code review

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-07-19 10:30:01 +02:00
Kristof Mariën
7810dc213a Load correct Renson fan speed when breeze level is set (#121960)
Renson: Load correct fan speed when breeze level is set
2024-07-19 10:17:45 +02:00
Brent Petit
ba5e3ca44b Remove use of deprecated set_aux_heat call from climate _async_reproduce_states (#121873) 2024-07-19 10:14:12 +02:00
rrooggiieerr
e2c6b7915e Buienradar textual improvements (#122095) 2024-07-19 10:12:58 +02:00
Jan Bouwhuis
5b4dd07189 Deprecate topic_template and payload_template for mqtt publish action (#122098)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2024-07-19 10:10:38 +02:00
G Johansson
362c772d67 Add config flow to worldclock (#121775) 2024-07-19 10:08:14 +02:00
Matrix
339b5117c5 Add default value for YoLink thermostat (#122114) 2024-07-19 10:05:24 +02:00
Sid
c8a6c6a5c1 Add fallback for webmin systems without MAC address (#113261) 2024-07-19 10:01:46 +02:00
Austin Mroczek
53870617e8 Add binary sensors to TotalConnect (#121888)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2024-07-19 09:35:54 +02:00
Mr. Bubbles
24144c5855 Fix KeyError in config flow of Bring integration (#122136) 2024-07-19 09:11:29 +02:00
ashalita
978de5b8b0 Upgrade pycoolmasternet-async to 0.2.0 (#122139) 2024-07-19 09:10:47 +02:00
Dave T
1e59ce2909 Update deprecation warning for data_entry_flow (#122154) 2024-07-19 09:08:45 +02:00
J. Nick Koston
fb5443fe2f Add missing coverage for doorbird config_flow (#122146) 2024-07-19 09:08:43 +02:00
J. Nick Koston
dae23a8153 Add coverage for doorbird button platform (#122145) 2024-07-19 09:07:58 +02:00
Brett Adams
a2c2488c8b Add Tesla Fleet integration (#122019)
* Add Tesla Fleet

* Remove debug

* Improvements

* Fix refresh and stage tests

* Working oauth flow test

* Config Flow tests

* Fixes

* fixes

* Remove comment

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

* Remove TYPE_CHECKING

* Add more tests

* More tests

* More tests

* revert teslemetry change

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2024-07-19 09:05:27 +02:00
Duco Sebel
474e8b7a43 Implement model_id in HomeWizard (#122130)
* Add model_id and use hardcoded model names for HomeWizard

* Update names
2024-07-19 08:22:06 +02:00
b3nj1
243c16d194 Opower: add date sensors (#122138)
opower: Add diagnostic date sensors
2024-07-18 23:07:09 -07:00
J. Nick Koston
a0a5f640dc Add some basic tests for doorbird (#122135)
* basic tests

* basic tests

* basic tests

* basic tests

* cover

* cover

* Update tests/components/doorbird/test_init.py
2024-07-18 22:36:54 +02:00
Shay Levy
d2cc25cee6 Prevent connecting to a Shelly device that is already connected (#122105) 2024-07-18 15:27:03 -05:00
Steven B.
cf0aef079b Bump tplink dependency python-kasa to 0.7.0.5 (#122119) 2024-07-18 15:20:10 -05:00
Arie Catsman
6d725b5e34 Extend sensor platform tests for enphase_envoy (#122132)
* EnphaseTestSensor

* refactor chain use in sensor test of enphase_envoy
2024-07-18 21:50:50 +02:00
Marcel van der Veldt
3d3bc1cab1 Revert "Add mac address as connection for matter device (#121257)" (#122133) 2024-07-18 17:38:30 +02:00
Maciej Bieniek
bf0e5baa76 Add support for Shelly enum virtual component (#121997)
* Support enum virtual component

* Add tests

* Cleaning

* Improve test for select

* Use values

* Update tests

* Use the option title for sensor

---------

Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com>
2024-07-18 17:55:14 +03:00
MatthewFlamm
f479b64ff9 Add forecast service call for extra attributes for nws (#117254)
* add service call

* fix snapshots in test

* add tests

* fix no data service;add test

* remove unreachable code

* use only extra attributes+context attributes

* detailed descr. only in twice daily; add dewpoint

* fix import from merge

* Remove dewpoint from twice daily.

nws recently removed it

* cleanup unused snapshots

* remove dewpoint; use short_forecast

* return [] for forecasts instead of None

* Use str for short_description

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

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2024-07-18 16:26:07 +02:00
Marc Mueller
ec937781ca Update pylint to 3.2.5 (#122126)
* Update pylint to 3.2.5

* Remove unused pylint disable comment
2024-07-18 15:54:54 +02:00
Arie Catsman
37426f7366 Add number platform test to enphase_envoy (#122117)
* Add number platform test to enphase_envoy

* Use ATTR_VALUE in enphase_envoy number test
2024-07-18 15:42:57 +02:00
Arie Catsman
d983e3b25d Add select platform test to enphase_envoy (#122127) 2024-07-18 14:20:05 +02:00
Marc Mueller
58d4e72996 Update yt-dlp to 2024.07.16 (#122124) 2024-07-18 14:07:31 +02:00
Josef Zweck
02bb1ec8e7 Add reconfigure step to tedee (#122008)
* Add reconfigure to tedee

* assert data update

* add rconfigure_confirm to strings

* Update integration name

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2024-07-18 13:00:35 +02:00
Arie Catsman
f551130d65 Add binary_sensor platform test to enphase_envoy (#122120) 2024-07-18 12:45:03 +02:00
Joakim Plate
42610f4e09 Add diagnostic information to DSMR (#122041)
* Add diagnostic information to DSMR

Switches to runtime_data to get access
to the last telegram received.

* Correct import of domain

* Apply suggestions from code review

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

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2024-07-18 11:57:11 +02:00
Franck Nijhof
41d75e159b Update wled to 0.19.2 (#122101)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-07-18 10:09:12 +02:00
Brett Adams
b06d3fe3b7 Platinum quality for Tessie (#121962) 2024-07-18 09:38:26 +02:00
J. Nick Koston
e4ef4b81ba Skip poll of HKC accessory if reachable and chars are watchable (#116200) 2024-07-18 08:36:45 +02:00
Erik Montnemery
0927dd9090 Raise repair issues when scripts can't be set up (#122087) 2024-07-18 08:34:41 +02:00
J. Nick Koston
e2276458ed Fix homekit_controller tests to avoid global aid generation (#119852) 2024-07-17 19:10:02 -05:00
Paulus Schoutsen
454ca0ce95 Add timer support to mobile app (#121469)
* Add timer support to mobile app

* Fix tests

* Make it time-sensitive
2024-07-17 18:40:05 -05:00
J. Nick Koston
4ae6e38800 Bump govee-ble to 0.38.0 (#122099) 2024-07-17 22:05:27 +02:00
Steven B.
55cee89392 Update tplink device config during reauth flow (#122089) 2024-07-17 14:07:53 -05:00
Michael Hansen
fa0a5451b9 Split up tests to avoid CI timeouts (#122096) 2024-07-17 20:32:26 +02:00
Aidan Timson
52b90621c7 System Bridge coordinator and connector refactor (#114896)
* Update systembridgeconnector to 5.0.0.dev2

* Refactor

* Move out of single use method

* Update systembridgeconnector to 4.1.0.dev0 and systembridgemodels to 4.1.0

* Refactor WebSocket connection handling in SystemBridgeDataUpdateCoordinator

* Remove unnessasary fluff

* Update system_bridge requirements to version 4.1.0.dev1

* Set systembridgeconnector to 4.1.0

* Fix config flow tests

We'll make this better later

* Add missing tests for media source

* Update config flow tests

* Add missing check

* Refactor WebSocket connection handling in SystemBridgeDataUpdateCoordinator

* Move inside try

* Move log

* Cleanup log

* Fix disconnection update

* Set unregistered on disconnect

* Remove bool, use listener task

* Fix eager start

* == -> is

* Reduce errors

* Update test
2024-07-17 18:39:24 +02:00
Jan Bouwhuis
843fae825f Revert "Remove stale template_topic code for mqtt publish service" (#121758)
Revert "Remove stale `template_topic` code for mqtt publish service (#121604)"

This reverts commit 5b25c24539.
2024-07-17 17:56:34 +02:00
Franck Nijhof
e6dec7c856 Migrate HomeWizard to config entry runtime data (#122088) 2024-07-17 10:20:31 -05:00
Franck Nijhof
7a4e40ade0 Remove Markdown from service action descriptions (#122077) 2024-07-17 10:20:19 -05:00
Robert Resch
10c084c6e0 Add created_at/modified_at to label registry (#122078) 2024-07-17 16:36:14 +02:00
Franck Nijhof
8ae4c4445d Clean up old migration in HomeWizard (#122086) 2024-07-17 16:18:21 +02:00
845 changed files with 43413 additions and 7586 deletions

View File

@@ -86,7 +86,7 @@ jobs:
tests_glob: ${{ steps.info.outputs.tests_glob }}
tests: ${{ steps.info.outputs.tests }}
skip_coverage: ${{ steps.info.outputs.skip_coverage }}
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- name: Check out code from GitHub
uses: actions/checkout@v4.1.7
@@ -218,7 +218,7 @@ jobs:
pre-commit:
name: Prepare pre-commit base
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
@@ -266,7 +266,7 @@ jobs:
lint-ruff-format:
name: Check ruff-format
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs:
- info
- pre-commit
@@ -306,7 +306,7 @@ jobs:
lint-ruff:
name: Check ruff
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs:
- info
- pre-commit
@@ -345,7 +345,7 @@ jobs:
RUFF_OUTPUT_FORMAT: github
lint-other:
name: Check other linters
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs:
- info
- pre-commit
@@ -437,7 +437,7 @@ jobs:
base:
name: Prepare dependencies
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs: info
timeout-minutes: 60
strategy:
@@ -514,7 +514,7 @@ jobs:
hassfest:
name: Check hassfest
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
@@ -552,7 +552,7 @@ jobs:
gen-requirements-all:
name: Check all requirements
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
@@ -584,7 +584,7 @@ jobs:
audit-licenses:
name: Audit licenses
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs:
- info
- base
@@ -624,7 +624,7 @@ jobs:
pylint:
name: Check pylint
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
timeout-minutes: 20
if: |
github.event.inputs.mypy-only != 'true'
@@ -669,7 +669,7 @@ jobs:
pylint-tests:
name: Check pylint on tests
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
timeout-minutes: 20
if: |
(github.event.inputs.mypy-only != 'true' || github.event.inputs.pylint-only == 'true')
@@ -714,7 +714,7 @@ jobs:
mypy:
name: Check mypy
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
if: |
github.event.inputs.pylint-only != 'true'
|| github.event.inputs.mypy-only == 'true'
@@ -775,7 +775,7 @@ jobs:
mypy homeassistant/components/${{ needs.info.outputs.integrations_glob }}
prepare-pytest-full:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
if: |
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
&& github.event.inputs.lint-only != 'true'
@@ -825,7 +825,7 @@ jobs:
overwrite: true
pytest-full:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
if: |
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
&& github.event.inputs.lint-only != 'true'
@@ -936,7 +936,7 @@ jobs:
./script/check_dirty
pytest-mariadb:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
services:
mariadb:
image: ${{ matrix.mariadb-group }}
@@ -1189,7 +1189,7 @@ jobs:
coverage-full:
name: Upload test coverage to Codecov (full suite)
if: needs.info.outputs.skip_coverage != 'true'
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs:
- info
- pytest-full
@@ -1213,7 +1213,7 @@ jobs:
version: v0.6.0
pytest-partial:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
if: |
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
&& github.event.inputs.lint-only != 'true'
@@ -1328,7 +1328,7 @@ jobs:
coverage-partial:
name: Upload test coverage to Codecov (partial suite)
if: needs.info.outputs.skip_coverage != 'true'
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs:
- info
- pytest-partial

View File

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

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.2
rev: v0.5.4
hooks:
- id: ruff
args:

View File

@@ -255,6 +255,7 @@ homeassistant.components.integration.*
homeassistant.components.intent.*
homeassistant.components.intent_script.*
homeassistant.components.ios.*
homeassistant.components.iotty.*
homeassistant.components.ipp.*
homeassistant.components.iqvia.*
homeassistant.components.islamic_prayer_times.*

View File

@@ -505,6 +505,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/generic_hygrostat/ @Shulyaka
/tests/components/generic_hygrostat/ @Shulyaka
/homeassistant/components/geniushub/ @manzanotti
/tests/components/geniushub/ @manzanotti
/homeassistant/components/geo_json_events/ @exxamalte
/tests/components/geo_json_events/ @exxamalte
/homeassistant/components/geo_location/ @home-assistant/core
@@ -695,6 +696,8 @@ build.json @home-assistant/supervisor
/tests/components/ios/ @robbiet480
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
/tests/components/iotawatt/ @gtdiehl @jyavenard
/homeassistant/components/iotty/ @pburgio
/tests/components/iotty/ @pburgio
/homeassistant/components/iperf3/ @rohankapoorcom
/homeassistant/components/ipma/ @dgomes
/tests/components/ipma/ @dgomes
@@ -1432,6 +1435,8 @@ build.json @home-assistant/supervisor
/tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks
/tests/components/tesla_wall_connector/ @einarhauks
/homeassistant/components/teslemetry/ @Bre77

View File

@@ -12,7 +12,7 @@ ENV \
ARG QEMU_CPU
# Install uv
RUN pip3 install uv==0.2.13
RUN pip3 install uv==0.2.27
WORKDIR /usr/src

View File

@@ -223,8 +223,10 @@ CRITICAL_INTEGRATIONS = {
SETUP_ORDER = (
# Load logging and http deps as soon as possible
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS),
# Setup frontend and recorder
("frontend, recorder", {*FRONTEND_INTEGRATIONS, *RECORDER_INTEGRATIONS}),
# Setup frontend
("frontend", FRONTEND_INTEGRATIONS),
# Setup recorder
("recorder", RECORDER_INTEGRATIONS),
# Start up debuggers. Start these first in case they want to wait.
("debugger", DEBUGGER_INTEGRATIONS),
)
@@ -906,7 +908,13 @@ async def _async_resolve_domains_to_setup(
await asyncio.gather(*resolve_dependencies_tasks)
for itg in integrations_to_process:
for dep in itg.all_dependencies:
try:
all_deps = itg.all_dependencies
except RuntimeError:
# Integration.all_dependencies raises RuntimeError if
# dependencies could not be resolved
continue
for dep in all_deps:
if dep in domains_to_setup:
continue
domains_to_setup.add(dep)

View File

@@ -1,5 +1,5 @@
{
"domain": "tesla",
"name": "Tesla",
"integrations": ["powerwall", "tesla_wall_connector"]
"integrations": ["powerwall", "tesla_wall_connector", "tesla_fleet"]
}

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
from airgradient import AirGradientClient
from airgradient import AirGradientClient, get_model_name
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
@@ -35,7 +35,7 @@ class AirGradientData:
type AirGradientConfigEntry = ConfigEntry[AirGradientData]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) -> bool:
"""Set up Airgradient from a config entry."""
client = AirGradientClient(
@@ -53,7 +53,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, measurement_coordinator.serial_number)},
manufacturer="AirGradient",
model=measurement_coordinator.data.model,
model=get_model_name(measurement_coordinator.data.model),
model_id=measurement_coordinator.data.model,
serial_number=measurement_coordinator.data.serial_number,
sw_version=measurement_coordinator.data.firmware_version,
)
@@ -68,6 +69,8 @@ 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: AirGradientConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -19,7 +19,6 @@ if TYPE_CHECKING:
class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""Class to manage fetching AirGradient data."""
_update_interval: timedelta
config_entry: AirGradientConfigEntry
def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None:
@@ -28,7 +27,7 @@ class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
hass,
logger=LOGGER,
name=f"AirGradient {client.host}",
update_interval=self._update_interval,
update_interval=timedelta(minutes=1),
)
self.client = client
assert self.config_entry.unique_id
@@ -47,8 +46,6 @@ class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
class AirGradientMeasurementCoordinator(AirGradientCoordinator[Measures]):
"""Class to manage fetching AirGradient data."""
_update_interval = timedelta(minutes=1)
async def _update_data(self) -> Measures:
return await self.client.get_current_measures()
@@ -56,7 +53,5 @@ class AirGradientMeasurementCoordinator(AirGradientCoordinator[Measures]):
class AirGradientConfigCoordinator(AirGradientCoordinator[Config]):
"""Class to manage fetching AirGradient data."""
_update_interval = timedelta(minutes=5)
async def _update_data(self) -> Config:
return await self.client.get_config()

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/airgradient",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["airgradient==0.6.1"],
"requirements": ["airgradient==0.7.0"],
"zeroconf": ["_airgradient._tcp.local."]
}

View File

@@ -7,9 +7,10 @@ from dataclasses import dataclass
from APsystemsEZ1 import APsystemsEZ1M
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, Platform
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from .const import DEFAULT_PORT
from .coordinator import ApSystemsDataCoordinator
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR]
@@ -28,7 +29,11 @@ type ApSystemsConfigEntry = ConfigEntry[ApSystemsData]
async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> bool:
"""Set up this integration using UI."""
api = APsystemsEZ1M(ip_address=entry.data[CONF_IP_ADDRESS], timeout=8)
api = APsystemsEZ1M(
ip_address=entry.data[CONF_IP_ADDRESS],
port=entry.data.get(CONF_PORT, DEFAULT_PORT),
timeout=8,
)
coordinator = ApSystemsDataCoordinator(hass, api)
await coordinator.async_config_entry_first_refresh()
assert entry.unique_id

View File

@@ -7,14 +7,16 @@ from APsystemsEZ1 import APsystemsEZ1M
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from .const import DOMAIN
from .const import DEFAULT_PORT, DOMAIN
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_IP_ADDRESS): str,
vol.Required(CONF_IP_ADDRESS): cv.string,
vol.Optional(CONF_PORT): cv.port,
}
)
@@ -32,7 +34,11 @@ class APsystemsLocalAPIFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
session = async_get_clientsession(self.hass, False)
api = APsystemsEZ1M(user_input[CONF_IP_ADDRESS], session=session)
api = APsystemsEZ1M(
ip_address=user_input[CONF_IP_ADDRESS],
port=user_input.get(CONF_PORT, DEFAULT_PORT),
session=session,
)
try:
device_info = await api.get_device_info()
except (TimeoutError, ClientConnectionError):

View File

@@ -4,3 +4,4 @@ from logging import Logger, getLogger
LOGGER: Logger = getLogger(__package__)
DOMAIN = "apsystems"
DEFAULT_PORT = 8050

View File

@@ -3,7 +3,11 @@
"step": {
"user": {
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]"
"ip_address": "[%key:common::config_flow::data::ip%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"port": "The integration will default to 8050, if not set, which should be suitable for most installs"
}
}
},

View File

@@ -6,8 +6,11 @@ from abc import ABC, abstractmethod
from collections.abc import Iterable
from dataclasses import dataclass
from enum import StrEnum
import logging
from typing import Final, cast
_LOGGER = logging.getLogger(__name__)
_SAMPLE_RATE: Final = 16000 # Hz
_SAMPLE_WIDTH: Final = 2 # bytes
@@ -159,6 +162,10 @@ class VoiceCommandSegmenter:
"""
self._timeout_seconds_left -= chunk_seconds
if self._timeout_seconds_left <= 0:
_LOGGER.warning(
"VAD end of speech detection timed out after %s seconds",
self.timeout_seconds,
)
self.reset()
return False

View File

@@ -81,6 +81,7 @@ SENSOR_TYPES_VIDEO_DOORBELL = (
SENSOR_TYPES_DOORBELL: tuple[AugustDoorbellBinarySensorEntityDescription, ...] = (
AugustDoorbellBinarySensorEntityDescription(
key="ding",
translation_key="ding",
device_class=BinarySensorDeviceClass.OCCUPANCY,
value_fn=retrieve_ding_activity,
is_time_based=True,

View File

@@ -40,6 +40,9 @@
},
"entity": {
"binary_sensor": {
"ding": {
"name": "Doorbell ding"
},
"image_capture": {
"name": "Image capture"
}

View File

@@ -333,7 +333,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def reload_service_handler(service_call: ServiceCall) -> None:
"""Remove all automations and load new ones from config."""
await async_get_blueprints(hass).async_reset_cache()
conf = await component.async_prepare_reload(skip_reset=True)
if (conf := await component.async_prepare_reload(skip_reset=True)) is None:
return
if automation_id := service_call.data.get(CONF_ID):
await _async_process_single_config(hass, conf, component, automation_id)
else:

View File

@@ -18,7 +18,7 @@ from homeassistant.core import Event, HomeAssistant, State
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.entityfilter import FILTER_SCHEMA
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.json import ExtendedJSONEncoder
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.dt import utcnow
@@ -203,9 +203,7 @@ class AzureDataExplorer:
return None, dropped
if (utcnow() - time_fired).seconds > DEFAULT_MAX_DELAY + self._send_interval:
return None, dropped + 1
if "\n" in state.state:
return None, dropped + 1
json_event = json.dumps(obj=state, cls=JSONEncoder)
json_event = json.dumps(obj=state, cls=ExtendedJSONEncoder)
return (json_event, dropped)

View File

@@ -68,7 +68,7 @@ class AzureDataExplorerClient:
# Queued is the only option supported on free tier of ADX
self.write_client = QueuedIngestClient(kcsb_ingest)
else:
self.write_client = ManagedStreamingIngestClient.from_dm_kcsb(kcsb_ingest)
self.write_client = ManagedStreamingIngestClient(kcsb_ingest)
self.query_client = KustoClient(kcsb_query)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/azure_data_explorer",
"iot_class": "cloud_push",
"loggers": ["azure"],
"requirements": ["azure-kusto-ingest==3.1.0", "azure-kusto-data[aio]==3.1.0"]
"requirements": ["azure-kusto-ingest==4.5.1", "azure-kusto-data[aio]==4.5.1"]
}

View File

@@ -43,7 +43,10 @@ class BAFFan(BAFEntity, FanEntity):
FanEntityFeature.SET_SPEED
| FanEntityFeature.DIRECTION
| FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
_enable_turn_on_off_backwards_compatibility = False
_attr_preset_modes = [PRESET_MODE_AUTO]
_attr_speed_count = SPEED_COUNT
_attr_name = None

View File

@@ -32,7 +32,12 @@ async def async_setup_entry(
class BalboaPumpFanEntity(BalboaEntity, FanEntity):
"""Representation of a Balboa Spa pump fan entity."""
_attr_supported_features = FanEntityFeature.SET_SPEED
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
_enable_turn_on_off_backwards_compatibility = False
_attr_translation_key = "pump"
def __init__(self, control: SpaControl) -> None:

View File

@@ -4,5 +4,5 @@
"codeowners": ["@thrawnarn"],
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"iot_class": "local_polling",
"requirements": ["xmltodict==0.13.0"]
"requirements": ["pyblu==0.4.0"]
}

View File

@@ -3,18 +3,15 @@
from __future__ import annotations
import asyncio
from asyncio import CancelledError, timeout
from datetime import timedelta
from http import HTTPStatus
from asyncio import CancelledError
from contextlib import suppress
from datetime import datetime, timedelta
import logging
from typing import Any, NamedTuple
from urllib import parse
import aiohttp
from aiohttp.client_exceptions import ClientError
from aiohttp.hdrs import CONNECTION, KEEP_ALIVE
from pyblu import Input, Player, Preset, Status, SyncStatus
import voluptuous as vol
import xmltodict
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@@ -36,6 +33,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import format_mac
@@ -109,7 +107,7 @@ SERVICE_TO_METHOD = {
}
def _add_player(hass, async_add_entities, host, port=None, name=None):
def _add_player(hass: HomeAssistant, async_add_entities, host, port=None, name=None):
"""Add Bluesound players."""
@callback
@@ -123,7 +121,7 @@ def _add_player(hass, async_add_entities, host, port=None, name=None):
player.start_polling()
@callback
def _stop_polling():
def _stop_polling(event=None):
"""Stop polling."""
player.stop_polling()
@@ -213,38 +211,38 @@ class BluesoundPlayer(MediaPlayerEntity):
_attr_media_content_type = MediaType.MUSIC
def __init__(self, hass, host, port=None, name=None, init_callback=None):
def __init__(
self, hass: HomeAssistant, host, port=None, name=None, init_callback=None
) -> None:
"""Initialize the media player."""
self.host = host
self._hass = hass
self.port = port
self._polling_session = async_get_clientsession(hass)
self._polling_task = None # The actual polling task.
self._name = name
self._id = None
self._capture_items = []
self._services_items = []
self._preset_items = []
self._sync_status = {}
self._status = None
self._last_status_update = None
self._sync_status: SyncStatus | None = None
self._status: Status | None = None
self._inputs: list[Input] = []
self._presets: list[Preset] = []
self._is_online = False
self._retry_remove = None
self._muted = False
self._master = None
self._master: BluesoundPlayer | None = None
self._is_master = False
self._group_name = None
self._group_list = []
self._group_list: list[str] = []
self._bluesound_device_name = None
self._player = Player(
host, port, async_get_clientsession(hass), default_timeout=10
)
self._init_callback = init_callback
if self.port is None:
self.port = DEFAULT_PORT
class _TimeoutException(Exception):
pass
@staticmethod
def _try_get_index(string, search_string):
"""Get the index."""
@@ -253,28 +251,22 @@ class BluesoundPlayer(MediaPlayerEntity):
except ValueError:
return -1
async def force_update_sync_status(self, on_updated_cb=None, raise_timeout=False):
async def force_update_sync_status(self, on_updated_cb=None) -> bool:
"""Update the internal status."""
resp = await self.send_bluesound_command(
"SyncStatus", raise_timeout, raise_timeout
)
sync_status = await self._player.sync_status()
if not resp:
return None
self._sync_status = resp["SyncStatus"].copy()
self._sync_status = sync_status
if not self._name:
self._name = self._sync_status.get("@name", self.host)
self._name = sync_status.name if sync_status.name else self.host
if not self._id:
self._id = self._sync_status.get("@id", None)
self._id = sync_status.id
if not self._bluesound_device_name:
self._bluesound_device_name = self._sync_status.get("@name", self.host)
self._bluesound_device_name = self._name
if (master := self._sync_status.get("master")) is not None:
if sync_status.master is not None:
self._is_master = False
master_host = master.get("#text")
master_port = master.get("@port", "11000")
master_id = f"{master_host}:{master_port}"
master_id = f"{sync_status.master.ip}:{sync_status.master.port}"
master_device = [
device
for device in self._hass.data[DATA_BLUESOUND]
@@ -289,7 +281,7 @@ class BluesoundPlayer(MediaPlayerEntity):
else:
if self._master is not None:
self._master = None
slaves = self._sync_status.get("slave")
slaves = self._sync_status.slaves
self._is_master = slaves is not None
if on_updated_cb:
@@ -302,7 +294,7 @@ class BluesoundPlayer(MediaPlayerEntity):
while True:
await self.async_update_status()
except (TimeoutError, ClientError, BluesoundPlayer._TimeoutException):
except (TimeoutError, ClientError):
_LOGGER.info("Node %s:%s is offline, retrying later", self.name, self.port)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
self.start_polling()
@@ -328,7 +320,7 @@ class BluesoundPlayer(MediaPlayerEntity):
self._retry_remove()
self._retry_remove = None
await self.force_update_sync_status(self._init_callback, True)
await self.force_update_sync_status(self._init_callback)
except (TimeoutError, ClientError):
_LOGGER.info("Node %s:%s is offline, retrying later", self.host, self.port)
self._retry_remove = async_track_time_interval(
@@ -345,110 +337,48 @@ class BluesoundPlayer(MediaPlayerEntity):
if not self._is_online:
return
await self.async_update_sync_status()
await self.async_update_presets()
await self.async_update_captures()
await self.async_update_services()
async def send_bluesound_command(
self, method, raise_timeout=False, allow_offline=False
):
"""Send command to the player."""
if not self._is_online and not allow_offline:
return None
if method[0] == "/":
method = method[1:]
url = f"http://{self.host}:{self.port}/{method}"
_LOGGER.debug("Calling URL: %s", url)
response = None
try:
websession = async_get_clientsession(self._hass)
async with timeout(10):
response = await websession.get(url)
if response.status == HTTPStatus.OK:
result = await response.text()
if result:
data = xmltodict.parse(result)
else:
data = None
elif response.status == 595:
_LOGGER.info("Status 595 returned, treating as timeout")
raise BluesoundPlayer._TimeoutException
else:
_LOGGER.error("Error %s on %s", response.status, url)
return None
except (TimeoutError, aiohttp.ClientError):
if raise_timeout:
_LOGGER.info("Timeout: %s:%s", self.host, self.port)
raise
_LOGGER.debug("Failed communicating: %s:%s", self.host, self.port)
return None
return data
with suppress(TimeoutError):
await self.async_update_sync_status()
await self.async_update_presets()
await self.async_update_captures()
async def async_update_status(self):
"""Use the poll session to always get the status of the player."""
response = None
url = "Status"
etag = ""
etag = None
if self._status is not None:
etag = self._status.get("@etag", "")
if etag != "":
url = f"Status?etag={etag}&timeout=120.0"
url = f"http://{self.host}:{self.port}/{url}"
_LOGGER.debug("Calling URL: %s", url)
etag = self._status.etag
try:
async with timeout(125):
response = await self._polling_session.get(
url, headers={CONNECTION: KEEP_ALIVE}
)
status = await self._player.status(etag=etag, poll_timeout=120, timeout=125)
if response.status == HTTPStatus.OK:
result = await response.text()
self._is_online = True
self._last_status_update = dt_util.utcnow()
self._status = xmltodict.parse(result)["status"].copy()
self._is_online = True
self._last_status_update = dt_util.utcnow()
self._status = status
group_name = self._status.get("groupName")
if group_name != self._group_name:
_LOGGER.debug("Group name change detected on device: %s", self.id)
self._group_name = group_name
group_name = status.group_name
if group_name != self._group_name:
_LOGGER.debug("Group name change detected on device: %s", self.id)
self._group_name = group_name
# rebuild ordered list of entity_ids that are in the group, master is first
self._group_list = self.rebuild_bluesound_group()
# rebuild ordered list of entity_ids that are in the group, master is first
self._group_list = self.rebuild_bluesound_group()
# the sleep is needed to make sure that the
# devices is synced
await asyncio.sleep(1)
await self.async_trigger_sync_on_all()
elif self.is_grouped:
# when player is grouped we need to fetch volume from
# sync_status. We will force an update if the player is
# grouped this isn't a foolproof solution. A better
# solution would be to fetch sync_status more often when
# the device is playing. This would solve a lot of
# problems. This change will be done when the
# communication is moved to a separate library
# the sleep is needed to make sure that the
# devices is synced
await asyncio.sleep(1)
await self.async_trigger_sync_on_all()
elif self.is_grouped:
# when player is grouped we need to fetch volume from
# sync_status. We will force an update if the player is
# grouped this isn't a foolproof solution. A better
# solution would be to fetch sync_status more often when
# the device is playing. This would solve a lot of
# problems. This change will be done when the
# communication is moved to a separate library
with suppress(TimeoutError):
await self.force_update_sync_status()
self.async_write_ha_state()
elif response.status == 595:
_LOGGER.info("Status 595 returned, treating as timeout")
raise BluesoundPlayer._TimeoutException
else:
_LOGGER.error(
"Error %s on %s. Trying one more time", response.status, url
)
self.async_write_ha_state()
except (TimeoutError, ClientError):
self._is_online = False
self._last_status_update = None
@@ -458,9 +388,10 @@ class BluesoundPlayer(MediaPlayerEntity):
raise
@property
def unique_id(self):
def unique_id(self) -> str | None:
"""Return an unique ID."""
return f"{format_mac(self._sync_status['@mac'])}-{self.port}"
assert self._sync_status is not None
return f"{format_mac(self._sync_status.mac)}-{self.port}"
async def async_trigger_sync_on_all(self):
"""Trigger sync status update on all devices."""
@@ -470,95 +401,25 @@ class BluesoundPlayer(MediaPlayerEntity):
await player.force_update_sync_status()
@Throttle(SYNC_STATUS_INTERVAL)
async def async_update_sync_status(self, on_updated_cb=None, raise_timeout=False):
async def async_update_sync_status(self, on_updated_cb=None):
"""Update sync status."""
await self.force_update_sync_status(on_updated_cb, raise_timeout=False)
await self.force_update_sync_status(on_updated_cb)
@Throttle(UPDATE_CAPTURE_INTERVAL)
async def async_update_captures(self):
async def async_update_captures(self) -> list[Input] | None:
"""Update Capture sources."""
resp = await self.send_bluesound_command("RadioBrowse?service=Capture")
if not resp:
return None
self._capture_items = []
inputs = await self._player.inputs()
self._inputs = inputs
def _create_capture_item(item):
self._capture_items.append(
{
"title": item.get("@text", ""),
"name": item.get("@text", ""),
"type": item.get("@serviceType", "Capture"),
"image": item.get("@image", ""),
"url": item.get("@URL", ""),
}
)
if "radiotime" in resp and "item" in resp["radiotime"]:
if isinstance(resp["radiotime"]["item"], list):
for item in resp["radiotime"]["item"]:
_create_capture_item(item)
else:
_create_capture_item(resp["radiotime"]["item"])
return self._capture_items
return inputs
@Throttle(UPDATE_PRESETS_INTERVAL)
async def async_update_presets(self):
async def async_update_presets(self) -> list[Preset] | None:
"""Update Presets."""
resp = await self.send_bluesound_command("Presets")
if not resp:
return None
self._preset_items = []
presets = await self._player.presets()
self._presets = presets
def _create_preset_item(item):
self._preset_items.append(
{
"title": item.get("@name", ""),
"name": item.get("@name", ""),
"type": "preset",
"image": item.get("@image", ""),
"is_raw_url": True,
"url2": item.get("@url", ""),
"url": f"Preset?id={item.get('@id', '')}",
}
)
if "presets" in resp and "preset" in resp["presets"]:
if isinstance(resp["presets"]["preset"], list):
for item in resp["presets"]["preset"]:
_create_preset_item(item)
else:
_create_preset_item(resp["presets"]["preset"])
return self._preset_items
@Throttle(UPDATE_SERVICES_INTERVAL)
async def async_update_services(self):
"""Update Services."""
resp = await self.send_bluesound_command("Services")
if not resp:
return None
self._services_items = []
def _create_service_item(item):
self._services_items.append(
{
"title": item.get("@displayname", ""),
"name": item.get("@name", ""),
"type": item.get("@type", ""),
"image": item.get("@icon", ""),
"url": item.get("@name", ""),
}
)
if "services" in resp and "service" in resp["services"]:
if isinstance(resp["services"]["service"], list):
for item in resp["services"]["service"]:
_create_service_item(item)
else:
_create_service_item(resp["services"]["service"])
return self._services_items
return presets
@property
def state(self) -> MediaPlayerState:
@@ -569,7 +430,7 @@ class BluesoundPlayer(MediaPlayerEntity):
if self.is_grouped and not self.is_master:
return MediaPlayerState.IDLE
status = self._status.get("state")
status = self._status.state
if status in ("pause", "stop"):
return MediaPlayerState.PAUSED
if status in ("stream", "play"):
@@ -577,15 +438,15 @@ class BluesoundPlayer(MediaPlayerEntity):
return MediaPlayerState.IDLE
@property
def media_title(self):
def media_title(self) -> str | None:
"""Title of current playing media."""
if self._status is None or (self.is_grouped and not self.is_master):
return None
return self._status.get("title1")
return self._status.name
@property
def media_artist(self):
def media_artist(self) -> str | None:
"""Artist of current playing media (Music track only)."""
if self._status is None:
return None
@@ -593,35 +454,33 @@ class BluesoundPlayer(MediaPlayerEntity):
if self.is_grouped and not self.is_master:
return self._group_name
if not (artist := self._status.get("artist")):
artist = self._status.get("title2")
return artist
return self._status.artist
@property
def media_album_name(self):
def media_album_name(self) -> str | None:
"""Artist of current playing media (Music track only)."""
if self._status is None or (self.is_grouped and not self.is_master):
return None
if not (album := self._status.get("album")):
album = self._status.get("title3")
return album
return self._status.album
@property
def media_image_url(self):
def media_image_url(self) -> str | None:
"""Image url of current playing media."""
if self._status is None or (self.is_grouped and not self.is_master):
return None
if not (url := self._status.get("image")):
url = self._status.image
if url is None:
return None
if url[0] == "/":
url = f"http://{self.host}:{self.port}{url}"
return url
@property
def media_position(self):
def media_position(self) -> int | None:
"""Position of current playing media in seconds."""
if self._status is None or (self.is_grouped and not self.is_master):
return None
@@ -630,154 +489,101 @@ class BluesoundPlayer(MediaPlayerEntity):
if self._last_status_update is None or mediastate == MediaPlayerState.IDLE:
return None
if (position := self._status.get("secs")) is None:
position = self._status.seconds
if position is None:
return None
position = float(position)
if mediastate == MediaPlayerState.PLAYING:
position += (dt_util.utcnow() - self._last_status_update).total_seconds()
return position
return int(position)
@property
def media_duration(self):
def media_duration(self) -> int | None:
"""Duration of current playing media in seconds."""
if self._status is None or (self.is_grouped and not self.is_master):
return None
if (duration := self._status.get("totlen")) is None:
duration = self._status.total_seconds
if duration is None:
return None
return float(duration)
return duration
@property
def media_position_updated_at(self):
def media_position_updated_at(self) -> datetime | None:
"""Last time status was updated."""
return self._last_status_update
@property
def volume_level(self):
def volume_level(self) -> float | None:
"""Volume level of the media player (0..1)."""
volume = self._status.get("volume")
if self.is_grouped:
volume = self._sync_status.get("@volume")
volume = None
if volume is not None:
return int(volume) / 100
return None
if self._status is not None:
volume = self._status.volume
if self.is_grouped and self._sync_status is not None:
volume = self._sync_status.volume
if volume is None:
return None
return volume / 100
@property
def is_volume_muted(self):
def is_volume_muted(self) -> bool:
"""Boolean if volume is currently muted."""
mute = self._status.get("mute")
if self.is_grouped:
mute = self._sync_status.get("@mute")
mute = False
if self._status is not None:
mute = self._status.mute
if self.is_grouped and self._sync_status is not None:
mute = self._sync_status.mute_volume is not None
if mute is not None:
mute = bool(int(mute))
return mute
@property
def id(self):
def id(self) -> str | None:
"""Get id of device."""
return self._id
@property
def name(self):
def name(self) -> str | None:
"""Return the name of the device."""
return self._name
@property
def bluesound_device_name(self):
def bluesound_device_name(self) -> str | None:
"""Return the device name as returned by the device."""
return self._bluesound_device_name
@property
def source_list(self):
def source_list(self) -> list[str] | None:
"""List of available input sources."""
if self._status is None or (self.is_grouped and not self.is_master):
return None
sources = [source["title"] for source in self._preset_items]
sources.extend(
source["title"]
for source in self._services_items
if source["type"] in ("LocalMusic", "RadioService")
)
sources.extend(source["title"] for source in self._capture_items)
sources = [x.text for x in self._inputs]
sources += [x.name for x in self._presets]
return sources
@property
def source(self):
def source(self) -> str | None:
"""Name of the current input source."""
if self._status is None or (self.is_grouped and not self.is_master):
return None
if (current_service := self._status.get("service", "")) == "":
return ""
stream_url = self._status.get("streamUrl", "")
if self._status.input_id is not None:
for input_ in self._inputs:
if input_.id == self._status.input_id:
return input_.text
if self._status.get("is_preset", "") == "1" and stream_url != "":
# This check doesn't work with all presets, for example playlists.
# But it works with radio service_items will catch playlists.
items = [
x
for x in self._preset_items
if "url2" in x and parse.unquote(x["url2"]) == stream_url
]
if items:
return items[0]["title"]
for preset in self._presets:
if preset.url == self._status.stream_url:
return preset.name
# This could be a bit difficult to detect. Bluetooth could be named
# different things and there is not any way to match chooses in
# capture list to current playing. It's a bit of guesswork.
# This method will be needing some tweaking over time.
title = self._status.get("title1", "").lower()
if title == "bluetooth" or stream_url == "Capture:hw:2,0/44100/16/2":
items = [
x
for x in self._capture_items
if x["url"] == "Capture%3Abluez%3Abluetooth"
]
if items:
return items[0]["title"]
items = [x for x in self._capture_items if x["url"] == stream_url]
if items:
return items[0]["title"]
if stream_url[:8] == "Capture:":
stream_url = stream_url[8:]
idx = BluesoundPlayer._try_get_index(stream_url, ":")
if idx > 0:
stream_url = stream_url[:idx]
for item in self._capture_items:
url = parse.unquote(item["url"])
if url[:8] == "Capture:":
url = url[8:]
idx = BluesoundPlayer._try_get_index(url, ":")
if idx > 0:
url = url[:idx]
if url.lower() == stream_url.lower():
return item["title"]
items = [x for x in self._capture_items if x["name"] == current_service]
if items:
return items[0]["title"]
items = [x for x in self._services_items if x["name"] == current_service]
if items:
return items[0]["title"]
if self._status.get("streamUrl", "") != "":
_LOGGER.debug(
"Couldn't find source of stream URL: %s",
self._status.get("streamUrl", ""),
)
return None
return self._status.service
@property
def supported_features(self) -> MediaPlayerEntityFeature:
@@ -797,7 +603,7 @@ class BluesoundPlayer(MediaPlayerEntity):
| MediaPlayerEntityFeature.BROWSE_MEDIA
)
if self._status.get("indexing", "0") == "0":
if not self._status.indexing:
supported = (
supported
| MediaPlayerEntityFeature.PAUSE
@@ -819,25 +625,29 @@ class BluesoundPlayer(MediaPlayerEntity):
| MediaPlayerEntityFeature.VOLUME_MUTE
)
if self._status.get("canSeek", "") == "1":
if self._status.can_seek:
supported = supported | MediaPlayerEntityFeature.SEEK
return supported
@property
def is_master(self):
def is_master(self) -> bool:
"""Return true if player is a coordinator."""
return self._is_master
@property
def is_grouped(self):
def is_grouped(self) -> bool:
"""Return true if player is a coordinator."""
return self._master is not None or self._is_master
@property
def shuffle(self):
def shuffle(self) -> bool:
"""Return true if shuffle is active."""
return self._status.get("shuffle", "0") == "1"
shuffle = False
if self._status is not None:
shuffle = self._status.shuffle
return shuffle
async def async_join(self, master):
"""Join the player to a group."""
@@ -847,7 +657,10 @@ class BluesoundPlayer(MediaPlayerEntity):
if device.entity_id == master
]
if master_device:
if len(master_device) > 0:
if self.id == master_device[0].id:
raise ServiceValidationError("Cannot join player to itself")
_LOGGER.debug(
"Trying to join player: %s to master: %s",
self.id,
@@ -859,9 +672,9 @@ class BluesoundPlayer(MediaPlayerEntity):
_LOGGER.error("Master not found %s", master_device)
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any] | None:
"""List members in group."""
attributes = {}
attributes: dict[str, Any] = {}
if self._group_list:
attributes = {ATTR_BLUESOUND_GROUP: self._group_list}
@@ -869,10 +682,10 @@ class BluesoundPlayer(MediaPlayerEntity):
return attributes
def rebuild_bluesound_group(self):
def rebuild_bluesound_group(self) -> list[str]:
"""Rebuild the list of entities in speaker group."""
if self._group_name is None:
return None
return []
device_group = self._group_name.split("+")
@@ -895,121 +708,92 @@ class BluesoundPlayer(MediaPlayerEntity):
_LOGGER.debug("Trying to unjoin player: %s", self.id)
await self._master.async_remove_slave(self)
async def async_add_slave(self, slave_device):
async def async_add_slave(self, slave_device: BluesoundPlayer):
"""Add slave to master."""
return await self.send_bluesound_command(
f"/AddSlave?slave={slave_device.host}&port={slave_device.port}"
)
await self._player.add_slave(slave_device.host, slave_device.port)
async def async_remove_slave(self, slave_device):
async def async_remove_slave(self, slave_device: BluesoundPlayer):
"""Remove slave to master."""
return await self.send_bluesound_command(
f"/RemoveSlave?slave={slave_device.host}&port={slave_device.port}"
)
await self._player.remove_slave(slave_device.host, slave_device.port)
async def async_increase_timer(self):
async def async_increase_timer(self) -> int:
"""Increase sleep time on player."""
sleep_time = await self.send_bluesound_command("/Sleep")
if sleep_time is None:
_LOGGER.error("Error while increasing sleep time on player: %s", self.id)
return 0
return int(sleep_time.get("sleep", "0"))
return await self._player.sleep_timer()
async def async_clear_timer(self):
"""Clear sleep timer on player."""
sleep = 1
while sleep > 0:
sleep = await self.async_increase_timer()
sleep = await self._player.sleep_timer()
async def async_set_shuffle(self, shuffle: bool) -> None:
"""Enable or disable shuffle mode."""
value = "1" if shuffle else "0"
return await self.send_bluesound_command(f"/Shuffle?state={value}")
await self._player.shuffle(shuffle)
async def async_select_source(self, source: str) -> None:
"""Select input source."""
if self.is_grouped and not self.is_master:
return
items = [x for x in self._preset_items if x["title"] == source]
# presets and inputs might have the same name; presets have priority
url: str | None = None
for input_ in self._inputs:
if input_.text == source:
url = input_.url
for preset in self._presets:
if preset.name == source:
url = preset.url
if not items:
items = [x for x in self._services_items if x["title"] == source]
if not items:
items = [x for x in self._capture_items if x["title"] == source]
if not items:
return
selected_source = items[0]
url = f"Play?url={selected_source['url']}&preset_id&image={selected_source['image']}"
if selected_source.get("is_raw_url"):
url = selected_source["url"]
await self.send_bluesound_command(url)
await self._player.play_url(url)
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""
if self.is_grouped and not self.is_master:
return
await self.send_bluesound_command("Clear")
await self._player.clear()
async def async_media_next_track(self) -> None:
"""Send media_next command to media player."""
if self.is_grouped and not self.is_master:
return
cmd = "Skip"
if self._status and "actions" in self._status:
for action in self._status["actions"]["action"]:
if "@name" in action and "@url" in action and action["@name"] == "skip":
cmd = action["@url"]
await self.send_bluesound_command(cmd)
await self._player.skip()
async def async_media_previous_track(self) -> None:
"""Send media_previous command to media player."""
if self.is_grouped and not self.is_master:
return
cmd = "Back"
if self._status and "actions" in self._status:
for action in self._status["actions"]["action"]:
if "@name" in action and "@url" in action and action["@name"] == "back":
cmd = action["@url"]
await self.send_bluesound_command(cmd)
await self._player.back()
async def async_media_play(self) -> None:
"""Send media_play command to media player."""
if self.is_grouped and not self.is_master:
return
await self.send_bluesound_command("Play")
await self._player.play()
async def async_media_pause(self) -> None:
"""Send media_pause command to media player."""
if self.is_grouped and not self.is_master:
return
await self.send_bluesound_command("Pause")
await self._player.pause()
async def async_media_stop(self) -> None:
"""Send stop command."""
if self.is_grouped and not self.is_master:
return
await self.send_bluesound_command("Pause")
await self._player.stop()
async def async_media_seek(self, position: float) -> None:
"""Send media_seek command to media player."""
if self.is_grouped and not self.is_master:
return
await self.send_bluesound_command(f"Play?seek={float(position)}")
await self._player.play(seek=int(position))
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
@@ -1024,39 +808,39 @@ class BluesoundPlayer(MediaPlayerEntity):
)
media_id = play_item.url
media_id = async_process_play_media_url(self.hass, media_id)
url = async_process_play_media_url(self.hass, media_id)
url = f"Play?url={media_id}"
await self.send_bluesound_command(url)
await self._player.play_url(url)
async def async_volume_up(self) -> None:
"""Volume up the media player."""
current_vol = self.volume_level
if not current_vol or current_vol >= 1:
return
await self.async_set_volume_level(current_vol + 0.01)
if self.volume_level is None:
return None
new_volume = self.volume_level + 0.01
new_volume = min(1, new_volume)
return await self.async_set_volume_level(new_volume)
async def async_volume_down(self) -> None:
"""Volume down the media player."""
current_vol = self.volume_level
if not current_vol or current_vol <= 0:
return
await self.async_set_volume_level(current_vol - 0.01)
if self.volume_level is None:
return None
new_volume = self.volume_level - 0.01
new_volume = max(0, new_volume)
return await self.async_set_volume_level(new_volume)
async def async_set_volume_level(self, volume: float) -> None:
"""Send volume_up command to media player."""
if volume < 0:
volume = 0
elif volume > 1:
volume = 1
await self.send_bluesound_command(f"Volume?level={float(volume) * 100}")
volume = int(volume * 100)
volume = min(100, volume)
volume = max(0, volume)
await self._player.volume(level=volume)
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command to media player."""
if mute:
await self.send_bluesound_command("Volume?mute=1")
await self.send_bluesound_command("Volume?mute=0")
await self._player.volume(mute=mute)
async def async_browse_media(
self,

View File

@@ -69,7 +69,7 @@ class BondFan(BondEntity, FanEntity):
super().__init__(data, device)
if self._device.has_action(Action.BREEZE_ON):
self._attr_preset_modes = [PRESET_MODE_BREEZE]
features = FanEntityFeature(0)
features = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
if self._device.supports_speed():
features |= FanEntityFeature.SET_SPEED
if self._device.supports_direction():

View File

@@ -58,7 +58,7 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN):
):
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=self.info["name"] or user_input[CONF_EMAIL], data=user_input
title=self.info.get("name") or user_input[CONF_EMAIL], data=user_input
)
return self.async_show_form(

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from functools import partial
import logging
from bthome_ble import BTHomeBluetoothDeviceData, SensorUpdate
@@ -12,7 +13,6 @@ from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfoBleak,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -29,6 +29,7 @@ from .const import (
BTHomeBleEvent,
)
from .coordinator import BTHomePassiveBluetoothProcessorCoordinator
from .types import BTHomeConfigEntry
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR]
@@ -37,16 +38,14 @@ _LOGGER = logging.getLogger(__name__)
def process_service_info(
hass: HomeAssistant,
entry: ConfigEntry,
data: BTHomeBluetoothDeviceData,
service_info: BluetoothServiceInfoBleak,
entry: BTHomeConfigEntry,
device_registry: DeviceRegistry,
service_info: BluetoothServiceInfoBleak,
) -> SensorUpdate:
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
coordinator = entry.runtime_data
data = coordinator.device_data
update = data.update(service_info)
coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
discovered_event_classes = coordinator.discovered_event_classes
if entry.data.get(CONF_SLEEPY_DEVICE, False) != data.sleepy_device:
hass.config_entries.async_update_entry(
@@ -117,7 +116,7 @@ def format_discovered_event_class(address: str) -> SignalType[str, BTHomeBleEven
return SignalType(f"{DOMAIN}_discovered_event_class_{address}")
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> bool:
"""Set up BTHome Bluetooth from a config entry."""
address = entry.unique_id
assert address is not None
@@ -128,34 +127,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
data = BTHomeBluetoothDeviceData(**kwargs)
device_registry = dr.async_get(hass)
coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (
BTHomePassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.PASSIVE,
update_method=lambda service_info: process_service_info(
hass, entry, data, service_info, device_registry
),
device_data=data,
discovered_event_classes=set(
entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, [])
),
connectable=False,
entry=entry,
)
event_classes = set(entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, ()))
coordinator = BTHomePassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.PASSIVE,
update_method=partial(process_service_info, hass, entry, device_registry),
device_data=data,
discovered_event_classes=event_classes,
connectable=False,
entry=entry,
)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(
coordinator.async_start()
) # only start after all platforms have had a chance to subscribe
# only start after all platforms have had a chance to subscribe
entry.async_on_unload(coordinator.async_start())
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -7,7 +7,6 @@ from bthome_ble import (
SensorUpdate,
)
from homeassistant import config_entries
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -21,12 +20,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
from .coordinator import (
BTHomePassiveBluetoothDataProcessor,
BTHomePassiveBluetoothProcessorCoordinator,
)
from .coordinator import BTHomePassiveBluetoothDataProcessor
from .device import device_key_to_bluetooth_entity_key
from .types import BTHomeConfigEntry
BINARY_SENSOR_DESCRIPTIONS = {
BTHomeBinarySensorDeviceClass.BATTERY: BinarySensorEntityDescription(
@@ -172,13 +168,11 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
entry: BTHomeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the BTHome BLE binary sensors."""
coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
coordinator = entry.runtime_data
processor = BTHomePassiveBluetoothDataProcessor(
sensor_update_to_bluetooth_data_update
)

View File

@@ -13,10 +13,10 @@ from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import CONF_SLEEPY_DEVICE
from .types import BTHomeConfigEntry
class BTHomePassiveBluetoothProcessorCoordinator(
@@ -33,7 +33,7 @@ class BTHomePassiveBluetoothProcessorCoordinator(
update_method: Callable[[BluetoothServiceInfoBleak], SensorUpdate],
device_data: BTHomeBluetoothDeviceData,
discovered_event_classes: set[str],
entry: ConfigEntry,
entry: BTHomeConfigEntry,
connectable: bool = False,
) -> None:
"""Initialize the BTHome Bluetooth Passive Update Processor Coordinator."""

View File

@@ -9,7 +9,6 @@ from homeassistant.components.event import (
EventEntity,
EventEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -24,7 +23,7 @@ from .const import (
EVENT_TYPE,
BTHomeBleEvent,
)
from .coordinator import BTHomePassiveBluetoothProcessorCoordinator
from .types import BTHomeConfigEntry
DESCRIPTIONS_BY_EVENT_CLASS = {
EVENT_CLASS_BUTTON: EventEntityDescription(
@@ -103,13 +102,11 @@ class BTHomeEventEntity(EventEntity):
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: BTHomeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up BTHome event."""
coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
coordinator = entry.runtime_data
address = coordinator.address
ent_reg = er.async_get(hass)
async_add_entities(

View File

@@ -9,7 +9,6 @@ from bthome_ble.const import (
ExtendedSensorDeviceClass as BTHomeExtendedSensorDeviceClass,
)
from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataUpdate,
PassiveBluetoothProcessorEntity,
@@ -45,12 +44,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
from .coordinator import (
BTHomePassiveBluetoothDataProcessor,
BTHomePassiveBluetoothProcessorCoordinator,
)
from .coordinator import BTHomePassiveBluetoothDataProcessor
from .device import device_key_to_bluetooth_entity_key
from .types import BTHomeConfigEntry
SENSOR_DESCRIPTIONS = {
# Acceleration (m/s²)
@@ -394,13 +390,11 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
entry: BTHomeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the BTHome BLE sensors."""
coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
coordinator = entry.runtime_data
processor = BTHomePassiveBluetoothDataProcessor(
sensor_update_to_bluetooth_data_update
)

View File

@@ -0,0 +1,10 @@
"""The BTHome Bluetooth integration."""
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigEntry
if TYPE_CHECKING:
from .coordinator import BTHomePassiveBluetoothProcessorCoordinator
type BTHomeConfigEntry = ConfigEntry[BTHomePassiveBluetoothProcessorCoordinator]

View File

@@ -19,9 +19,9 @@
"step": {
"init": {
"data": {
"country_code": "Country code of the country to display camera images.",
"delta": "Time interval in seconds between camera image updates",
"timeframe": "Minutes to look ahead for precipitation forecast"
"country_code": "Country to display camera images for.",
"delta": "Interval between camera image updates",
"timeframe": "Time to look ahead for precipitation forecast"
}
}
}

View File

@@ -10,7 +10,6 @@ from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import Context, HomeAssistant, State
from .const import (
ATTR_AUX_HEAT,
ATTR_FAN_MODE,
ATTR_HUMIDITY,
ATTR_HVAC_MODE,
@@ -20,7 +19,6 @@ from .const import (
ATTR_TARGET_TEMP_LOW,
DOMAIN,
HVAC_MODES,
SERVICE_SET_AUX_HEAT,
SERVICE_SET_FAN_MODE,
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
@@ -56,9 +54,6 @@ async def _async_reproduce_states(
if state.state in HVAC_MODES:
await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state})
if ATTR_AUX_HEAT in state.attributes:
await call_service(SERVICE_SET_AUX_HEAT, [ATTR_AUX_HEAT])
if (
(ATTR_TEMPERATURE in state.attributes)
or (ATTR_TARGET_TEMP_HIGH in state.attributes)

View File

@@ -62,7 +62,13 @@ class ComfoConnectFan(FanEntity):
_attr_icon = "mdi:air-conditioner"
_attr_should_poll = False
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
_enable_turn_on_off_backwards_compatibility = False
_attr_preset_modes = PRESET_MODES
current_speed: float | None = None

View File

@@ -138,10 +138,14 @@ async def websocket_remove_config_entry_from_device(
"Failed to remove device entry, rejected by integration"
)
entry = registry.async_update_device(
device_id, remove_config_entry_id=config_entry_id
)
# Integration might have removed the config entry already, that is fine.
if registry.async_get(device_id):
entry = registry.async_update_device(
device_id, remove_config_entry_id=config_entry_id
)
entry_as_dict = entry.dict_repr if entry else None
entry_as_dict = entry.dict_repr if entry else None
else:
entry_as_dict = None
connection.send_message(websocket_api.result_message(msg["id"], entry_as_dict))

View File

@@ -132,8 +132,10 @@ def _entry_dict(entry: FloorEntry) -> dict[str, Any]:
"""Convert entry to API format."""
return {
"aliases": list(entry.aliases),
"created_at": entry.created_at.timestamp(),
"floor_id": entry.floor_id,
"icon": entry.icon,
"level": entry.level,
"name": entry.name,
"modified_at": entry.modified_at.timestamp(),
}

View File

@@ -157,8 +157,10 @@ def _entry_dict(entry: LabelEntry) -> dict[str, Any]:
"""Convert entry to API format."""
return {
"color": entry.color,
"created_at": entry.created_at.timestamp(),
"description": entry.description,
"icon": entry.icon,
"label_id": entry.label_id,
"name": entry.name,
"modified_at": entry.modified_at.timestamp(),
}

View File

@@ -94,7 +94,7 @@ def async_conversation_trace_append(
@contextmanager
def async_conversation_trace() -> Generator[ConversationTrace, None]:
def async_conversation_trace() -> Generator[ConversationTrace]:
"""Create a new active ConversationTrace."""
trace = ConversationTrace()
token = _current_trace.set(trace)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/coolmaster",
"iot_class": "local_polling",
"loggers": ["pycoolmasternet_async"],
"requirements": ["pycoolmasternet-async==0.1.5"]
"requirements": ["pycoolmasternet-async==0.2.0"]
}

View File

@@ -56,7 +56,12 @@ class DeconzFan(DeconzDevice[Light], FanEntity):
TYPE = DOMAIN
_default_on_speed = LightFanSpeed.PERCENT_50
_attr_supported_features = FanEntityFeature.SET_SPEED
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
_enable_turn_on_off_backwards_compatibility = False
def __init__(self, device: Light, hub: DeconzHub) -> None:
"""Set up fan."""

View File

@@ -15,9 +15,15 @@ PRESET_MODE_SLEEP = "sleep"
PRESET_MODE_ON = "on"
FULL_SUPPORT = (
FanEntityFeature.SET_SPEED | FanEntityFeature.OSCILLATE | FanEntityFeature.DIRECTION
FanEntityFeature.SET_SPEED
| FanEntityFeature.OSCILLATE
| FanEntityFeature.DIRECTION
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
LIMITED_SUPPORT = (
FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
)
LIMITED_SUPPORT = FanEntityFeature.SET_SPEED
async def async_setup_entry(
@@ -75,7 +81,9 @@ async def async_setup_entry(
hass,
"fan5",
"Preset Only Limited Fan",
FanEntityFeature.PRESET_MODE,
FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON,
[
PRESET_MODE_AUTO,
PRESET_MODE_SMART,
@@ -92,6 +100,7 @@ class BaseDemoFan(FanEntity):
_attr_should_poll = False
_attr_translation_key = "demo"
_enable_turn_on_off_backwards_compatibility = False
def __init__(
self,

View File

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

View File

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

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.3.0"]
"requirements": ["pydoods==1.0.2", "Pillow==10.4.0"]
}

View File

@@ -2,18 +2,32 @@
from __future__ import annotations
from asyncio import CancelledError
from asyncio import CancelledError, Task
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from dsmr_parser.objects import Telegram
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from .const import CONF_DSMR_VERSION, DATA_TASK, DOMAIN, PLATFORMS
from .const import CONF_DSMR_VERSION, PLATFORMS
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@dataclass
class DsmrState:
"""State of integration."""
task: Task | None = None
telegram: Telegram | None = None
type DsmrConfigEntry = ConfigEntry[DsmrState]
async def async_setup_entry(hass: HomeAssistant, entry: DsmrConfigEntry) -> bool:
"""Set up DSMR from a config entry."""
@callback
@@ -25,32 +39,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await er.async_migrate_entries(hass, entry.entry_id, _async_migrate_entity_entry)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {}
entry.runtime_data = DsmrState()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: DsmrConfigEntry) -> bool:
"""Unload a config entry."""
task = hass.data[DOMAIN][entry.entry_id][DATA_TASK]
# Cancel the reconnect task
task.cancel()
with suppress(CancelledError):
await task
if task := entry.runtime_data.task:
task.cancel()
with suppress(CancelledError):
await task
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_update_options(hass: HomeAssistant, entry: DsmrConfigEntry) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -23,8 +23,6 @@ DEFAULT_PRECISION = 3
DEFAULT_RECONNECT_INTERVAL = 30
DEFAULT_TIME_BETWEEN_UPDATE = 30
DATA_TASK = "task"
DEVICE_NAME_ELECTRICITY = "Electricity Meter"
DEVICE_NAME_GAS = "Gas Meter"
DEVICE_NAME_WATER = "Water Meter"

View File

@@ -0,0 +1,28 @@
"""Diagnostics support for DSMR."""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.util.json import json_loads
from . import DsmrConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: DsmrConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry": {
"data": {
**config_entry.data,
},
"unique_id": config_entry.unique_id,
},
"data": json_loads(config_entry.runtime_data.telegram.to_json())
if config_entry.runtime_data.telegram
else None,
}

View File

@@ -10,7 +10,6 @@ from dataclasses import dataclass
from datetime import timedelta
from functools import partial
from dsmr_parser import obis_references
from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader
from dsmr_parser.clients.rfxtrx_protocol import (
create_rfxtrx_dsmr_reader,
@@ -46,12 +45,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import Throttle
from . import DsmrConfigEntry
from .const import (
CONF_DSMR_VERSION,
CONF_SERIAL_ID,
CONF_SERIAL_ID_GAS,
CONF_TIME_BETWEEN_UPDATE,
DATA_TASK,
DEFAULT_PRECISION,
DEFAULT_RECONNECT_INTERVAL,
DEFAULT_TIME_BETWEEN_UPDATE,
@@ -81,7 +80,7 @@ class DSMRSensorEntityDescription(SensorEntityDescription):
SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="timestamp",
obis_reference=obis_references.P1_MESSAGE_TIMESTAMP,
obis_reference="P1_MESSAGE_TIMESTAMP",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@@ -89,21 +88,21 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="current_electricity_usage",
translation_key="current_electricity_usage",
obis_reference=obis_references.CURRENT_ELECTRICITY_USAGE,
obis_reference="CURRENT_ELECTRICITY_USAGE",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="current_electricity_delivery",
translation_key="current_electricity_delivery",
obis_reference=obis_references.CURRENT_ELECTRICITY_DELIVERY,
obis_reference="CURRENT_ELECTRICITY_DELIVERY",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="electricity_active_tariff",
translation_key="electricity_active_tariff",
obis_reference=obis_references.ELECTRICITY_ACTIVE_TARIFF,
obis_reference="ELECTRICITY_ACTIVE_TARIFF",
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENUM,
options=["low", "normal"],
@@ -111,7 +110,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="electricity_used_tariff_1",
translation_key="electricity_used_tariff_1",
obis_reference=obis_references.ELECTRICITY_USED_TARIFF_1,
obis_reference="ELECTRICITY_USED_TARIFF_1",
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -119,7 +118,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="electricity_used_tariff_2",
translation_key="electricity_used_tariff_2",
obis_reference=obis_references.ELECTRICITY_USED_TARIFF_2,
obis_reference="ELECTRICITY_USED_TARIFF_2",
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -127,7 +126,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="electricity_delivered_tariff_1",
translation_key="electricity_delivered_tariff_1",
obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_1,
obis_reference="ELECTRICITY_DELIVERED_TARIFF_1",
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -135,7 +134,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="electricity_delivered_tariff_2",
translation_key="electricity_delivered_tariff_2",
obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_2,
obis_reference="ELECTRICITY_DELIVERED_TARIFF_2",
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -143,7 +142,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="instantaneous_active_power_l1_positive",
translation_key="instantaneous_active_power_l1_positive",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE,
obis_reference="INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE",
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@@ -151,7 +150,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="instantaneous_active_power_l2_positive",
translation_key="instantaneous_active_power_l2_positive",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE,
obis_reference="INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE",
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@@ -159,7 +158,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="instantaneous_active_power_l3_positive",
translation_key="instantaneous_active_power_l3_positive",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE,
obis_reference="INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE",
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@@ -167,7 +166,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="instantaneous_active_power_l1_negative",
translation_key="instantaneous_active_power_l1_negative",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE,
obis_reference="INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE",
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@@ -175,7 +174,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="instantaneous_active_power_l2_negative",
translation_key="instantaneous_active_power_l2_negative",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE,
obis_reference="INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE",
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@@ -183,7 +182,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="instantaneous_active_power_l3_negative",
translation_key="instantaneous_active_power_l3_negative",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE,
obis_reference="INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE",
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@@ -191,7 +190,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="short_power_failure_count",
translation_key="short_power_failure_count",
obis_reference=obis_references.SHORT_POWER_FAILURE_COUNT,
obis_reference="SHORT_POWER_FAILURE_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -199,7 +198,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="long_power_failure_count",
translation_key="long_power_failure_count",
obis_reference=obis_references.LONG_POWER_FAILURE_COUNT,
obis_reference="LONG_POWER_FAILURE_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -207,7 +206,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="voltage_sag_l1_count",
translation_key="voltage_sag_l1_count",
obis_reference=obis_references.VOLTAGE_SAG_L1_COUNT,
obis_reference="VOLTAGE_SAG_L1_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -215,7 +214,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="voltage_sag_l2_count",
translation_key="voltage_sag_l2_count",
obis_reference=obis_references.VOLTAGE_SAG_L2_COUNT,
obis_reference="VOLTAGE_SAG_L2_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -223,7 +222,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="voltage_sag_l3_count",
translation_key="voltage_sag_l3_count",
obis_reference=obis_references.VOLTAGE_SAG_L3_COUNT,
obis_reference="VOLTAGE_SAG_L3_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -231,7 +230,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="voltage_swell_l1_count",
translation_key="voltage_swell_l1_count",
obis_reference=obis_references.VOLTAGE_SWELL_L1_COUNT,
obis_reference="VOLTAGE_SWELL_L1_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -239,7 +238,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="voltage_swell_l2_count",
translation_key="voltage_swell_l2_count",
obis_reference=obis_references.VOLTAGE_SWELL_L2_COUNT,
obis_reference="VOLTAGE_SWELL_L2_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -247,7 +246,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="voltage_swell_l3_count",
translation_key="voltage_swell_l3_count",
obis_reference=obis_references.VOLTAGE_SWELL_L3_COUNT,
obis_reference="VOLTAGE_SWELL_L3_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -255,7 +254,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="instantaneous_voltage_l1",
translation_key="instantaneous_voltage_l1",
obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L1,
obis_reference="INSTANTANEOUS_VOLTAGE_L1",
device_class=SensorDeviceClass.VOLTAGE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@@ -264,7 +263,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="instantaneous_voltage_l2",
translation_key="instantaneous_voltage_l2",
obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L2,
obis_reference="INSTANTANEOUS_VOLTAGE_L2",
device_class=SensorDeviceClass.VOLTAGE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@@ -273,7 +272,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="instantaneous_voltage_l3",
translation_key="instantaneous_voltage_l3",
obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L3,
obis_reference="INSTANTANEOUS_VOLTAGE_L3",
device_class=SensorDeviceClass.VOLTAGE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@@ -282,7 +281,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="instantaneous_current_l1",
translation_key="instantaneous_current_l1",
obis_reference=obis_references.INSTANTANEOUS_CURRENT_L1,
obis_reference="INSTANTANEOUS_CURRENT_L1",
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@@ -291,7 +290,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="instantaneous_current_l2",
translation_key="instantaneous_current_l2",
obis_reference=obis_references.INSTANTANEOUS_CURRENT_L2,
obis_reference="INSTANTANEOUS_CURRENT_L2",
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@@ -300,7 +299,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="instantaneous_current_l3",
translation_key="instantaneous_current_l3",
obis_reference=obis_references.INSTANTANEOUS_CURRENT_L3,
obis_reference="INSTANTANEOUS_CURRENT_L3",
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@@ -309,7 +308,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="belgium_max_power_per_phase",
translation_key="max_power_per_phase",
obis_reference=obis_references.BELGIUM_MAX_POWER_PER_PHASE,
obis_reference="ACTUAL_TRESHOLD_ELECTRICITY",
dsmr_versions={"5B"},
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
@@ -319,7 +318,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="belgium_max_current_per_phase",
translation_key="max_current_per_phase",
obis_reference=obis_references.BELGIUM_MAX_CURRENT_PER_PHASE,
obis_reference="BELGIUM_MAX_CURRENT_PER_PHASE",
dsmr_versions={"5B"},
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
@@ -329,7 +328,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="electricity_imported_total",
translation_key="electricity_imported_total",
obis_reference=obis_references.ELECTRICITY_IMPORTED_TOTAL,
obis_reference="ELECTRICITY_IMPORTED_TOTAL",
dsmr_versions={"5L", "5S", "Q3D"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -337,7 +336,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="electricity_exported_total",
translation_key="electricity_exported_total",
obis_reference=obis_references.ELECTRICITY_EXPORTED_TOTAL,
obis_reference="ELECTRICITY_EXPORTED_TOTAL",
dsmr_versions={"5L", "5S", "Q3D"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -345,7 +344,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="belgium_current_average_demand",
translation_key="current_average_demand",
obis_reference=obis_references.BELGIUM_CURRENT_AVERAGE_DEMAND,
obis_reference="BELGIUM_CURRENT_AVERAGE_DEMAND",
dsmr_versions={"5B"},
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
@@ -353,7 +352,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="belgium_maximum_demand_current_month",
translation_key="maximum_demand_current_month",
obis_reference=obis_references.BELGIUM_MAXIMUM_DEMAND_MONTH,
obis_reference="BELGIUM_MAXIMUM_DEMAND_MONTH",
dsmr_versions={"5B"},
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
@@ -361,7 +360,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="hourly_gas_meter_reading",
translation_key="gas_meter_reading",
obis_reference=obis_references.HOURLY_GAS_METER_READING,
obis_reference="HOURLY_GAS_METER_READING",
dsmr_versions={"4", "5", "5L"},
is_gas=True,
device_class=SensorDeviceClass.GAS,
@@ -370,7 +369,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="gas_meter_reading",
translation_key="gas_meter_reading",
obis_reference=obis_references.GAS_METER_READING,
obis_reference="GAS_METER_READING",
dsmr_versions={"2.2"},
is_gas=True,
device_class=SensorDeviceClass.GAS,
@@ -383,36 +382,20 @@ def create_mbus_entity(
mbus: int, mtype: int, telegram: Telegram
) -> DSMRSensorEntityDescription | None:
"""Create a new MBUS Entity."""
if (
mtype == 3
and (
obis_reference := getattr(
obis_references, f"BELGIUM_MBUS{mbus}_METER_READING2"
)
)
in telegram
):
if mtype == 3 and hasattr(telegram, f"BELGIUM_MBUS{mbus}_METER_READING2"):
return DSMRSensorEntityDescription(
key=f"mbus{mbus}_gas_reading",
translation_key="gas_meter_reading",
obis_reference=obis_reference,
obis_reference=f"BELGIUM_MBUS{mbus}_METER_READING2",
is_gas=True,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
)
if (
mtype == 7
and (
obis_reference := getattr(
obis_references, f"BELGIUM_MBUS{mbus}_METER_READING1"
)
)
in telegram
):
if mtype == 7 and (hasattr(telegram, f"BELGIUM_MBUS{mbus}_METER_READING1")):
return DSMRSensorEntityDescription(
key=f"mbus{mbus}_water_reading",
translation_key="water_meter_reading",
obis_reference=obis_reference,
obis_reference=f"BELGIUM_MBUS{mbus}_METER_READING1",
is_water=True,
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -425,7 +408,7 @@ def device_class_and_uom(
entity_description: DSMRSensorEntityDescription,
) -> tuple[SensorDeviceClass | None, str | None]:
"""Get native unit of measurement from telegram,."""
dsmr_object = telegram[entity_description.obis_reference]
dsmr_object = getattr(telegram, entity_description.obis_reference)
uom: str | None = getattr(dsmr_object, "unit") or None
with suppress(ValueError):
if entity_description.device_class == SensorDeviceClass.GAS and (
@@ -484,18 +467,15 @@ def create_mbus_entities(
entities = []
for idx in range(1, 5):
if (
device_type := getattr(obis_references, f"BELGIUM_MBUS{idx}_DEVICE_TYPE")
) not in telegram:
device_type := getattr(telegram, f"BELGIUM_MBUS{idx}_DEVICE_TYPE", None)
) is None:
continue
if (type_ := int(telegram[device_type].value)) not in (3, 7):
if (type_ := int(device_type.value)) not in (3, 7):
continue
if (
identifier := getattr(
obis_references,
f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER",
)
) in telegram:
serial_ = telegram[identifier].value
if identifier := getattr(
telegram, f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER", None
):
serial_ = identifier.value
rename_old_gas_to_mbus(hass, entry, serial_)
else:
serial_ = ""
@@ -514,7 +494,7 @@ def create_mbus_entities(
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
hass: HomeAssistant, entry: DsmrConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the DSMR sensor."""
dsmr_version = entry.data[CONF_DSMR_VERSION]
@@ -547,7 +527,7 @@ async def async_setup_entry(
or dsmr_version in description.dsmr_versions
)
and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data)
and description.obis_reference in telegram
and hasattr(telegram, description.obis_reference)
]
)
async_add_entities(entities)
@@ -567,6 +547,8 @@ async def async_setup_entry(
for entity in entities:
entity.update_data(telegram)
entry.runtime_data.telegram = telegram
if not initialized and telegram:
initialized = True
async_dispatcher_send(
@@ -695,7 +677,7 @@ async def async_setup_entry(
)
# Save the task to be able to cancel it when unloading
hass.data[DOMAIN][entry.entry_id][DATA_TASK] = task
entry.runtime_data.task = task
class DSMREntity(SensorEntity):
@@ -754,21 +736,21 @@ class DSMREntity(SensorEntity):
"""Update data."""
self.telegram = telegram
if self.hass and (
telegram is None or self.entity_description.obis_reference in telegram
telegram is None
or hasattr(telegram, self.entity_description.obis_reference)
):
self.async_write_ha_state()
def get_dsmr_object_attr(self, attribute: str) -> str | None:
"""Read attribute from last received telegram for this DSMR object."""
# Make sure telegram contains an object for this entities obis
if (
self.telegram is None
or self.entity_description.obis_reference not in self.telegram
if self.telegram is None or not hasattr(
self.telegram, self.entity_description.obis_reference
):
return None
# Get the attribute value if the object has it
dsmr_object = self.telegram[self.entity_description.obis_reference]
dsmr_object = getattr(self.telegram, self.entity_description.obis_reference)
attr: str | None = getattr(dsmr_object, attribute)
return attr
@@ -784,10 +766,7 @@ class DSMREntity(SensorEntity):
if (value := self.get_dsmr_object_attr("value")) is None:
return None
if (
self.entity_description.obis_reference
== obis_references.ELECTRICITY_ACTIVE_TARIFF
):
if self.entity_description.obis_reference == "ELECTRICITY_ACTIVE_TARIFF":
return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION])
with suppress(TypeError):

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from pyecoforest.api import EcoforestApi
from pyecoforest.models.device import Device
@@ -61,12 +62,12 @@ class EcoforestSwitchEntity(EcoforestEntity, SwitchEntity):
"""Return the state of the ecoforest device."""
return self.entity_description.value_fn(self.data)
async def async_turn_on(self):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the ecoforest device."""
await self.entity_description.switch_fn(self.coordinator.api, True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the ecoforest device."""
await self.entity_description.switch_fn(self.coordinator.api, False)
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,11 @@
"""Constants for the emoncms integration."""
import logging
CONF_EXCLUDE_FEEDID = "exclude_feed_id"
CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id"
CONF_MESSAGE = "message"
CONF_SUCCESS = "success"
LOGGER = logging.getLogger(__package__)

View File

@@ -1,15 +1,14 @@
"""DataUpdateCoordinator for the emoncms integration."""
from datetime import timedelta
import logging
from typing import Any
from pyemoncms import EmoncmsClient
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
from .const import CONF_MESSAGE, CONF_SUCCESS, LOGGER
class EmoncmsCoordinator(DataUpdateCoordinator[list[dict[str, Any]] | None]):
@@ -24,8 +23,15 @@ class EmoncmsCoordinator(DataUpdateCoordinator[list[dict[str, Any]] | None]):
"""Initialize the emoncms data coordinator."""
super().__init__(
hass,
_LOGGER,
LOGGER,
name="emoncms_coordinator",
update_method=emoncms_client.async_list_feeds,
update_interval=scan_interval,
)
self.emoncms_client = emoncms_client
async def _async_update_data(self) -> list[dict[str, Any]]:
"""Fetch data from API endpoint."""
data = await self.emoncms_client.async_request("/feed/list.json")
if not data[CONF_SUCCESS]:
raise UpdateFailed
return data[CONF_MESSAGE]

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from pyemoncms import EmoncmsClient
@@ -33,10 +32,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_EXCLUDE_FEEDID, CONF_ONLY_INCLUDE_FEEDID
from .coordinator import EmoncmsCoordinator
_LOGGER = logging.getLogger(__name__)
ATTR_FEEDID = "FeedId"
ATTR_FEEDNAME = "FeedName"
ATTR_LASTUPDATETIME = "LastUpdated"
@@ -45,8 +43,6 @@ ATTR_SIZE = "Size"
ATTR_TAG = "Tag"
ATTR_USERID = "UserId"
CONF_EXCLUDE_FEEDID = "exclude_feed_id"
CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id"
CONF_SENSOR_NAMES = "sensor_names"
DECIMALS = 2
@@ -98,7 +94,7 @@ async def async_setup_platform(
coordinator = EmoncmsCoordinator(hass, emoncms_client, scan_interval)
await coordinator.async_refresh()
elems = coordinator.data
if elems is None:
if not elems:
return
sensors: list[EmonCmsSensor] = []
@@ -208,7 +204,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
self._attr_native_value = None
if self._value_template is not None:
self._attr_native_value = (
self._value_template.render_with_possible_json_value(
self._value_template.async_render_with_possible_json_value(
elem["value"], STATE_UNKNOWN
)
)

View File

@@ -1,45 +1,23 @@
"""Support for Enigma2 devices."""
from openwebif.api import OpenWebIfDevice
from yarl import URL
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import CONF_SOURCE_BOUQUET
from .coordinator import Enigma2UpdateCoordinator
type Enigma2ConfigEntry = ConfigEntry[OpenWebIfDevice]
type Enigma2ConfigEntry = ConfigEntry[Enigma2UpdateCoordinator]
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: Enigma2ConfigEntry) -> bool:
"""Set up Enigma2 from a config entry."""
base_url = URL.build(
scheme="http" if not entry.data[CONF_SSL] else "https",
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
user=entry.data.get(CONF_USERNAME),
password=entry.data.get(CONF_PASSWORD),
)
session = async_create_clientsession(
hass, verify_ssl=entry.data[CONF_VERIFY_SSL], base_url=base_url
)
coordinator = Enigma2UpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
entry.runtime_data = OpenWebIfDevice(
session, source_bouquet=entry.options.get(CONF_SOURCE_BOUQUET)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@@ -68,8 +68,9 @@ CONFIG_SCHEMA = vol.Schema(
async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Get the options schema."""
entry = cast(SchemaOptionsFlowHandler, handler.parent_handler).config_entry
device: OpenWebIfDevice = entry.runtime_data
bouquets = [b[1] for b in (await device.get_all_bouquets())["bouquets"]]
bouquets = [
b[1] for b in (await entry.runtime_data.device.get_all_bouquets())["bouquets"]
]
return vol.Schema(
{

View File

@@ -0,0 +1,84 @@
"""Data update coordinator for the Enigma2 integration."""
import logging
from openwebif.api import OpenWebIfDevice, OpenWebIfStatus
from yarl import URL
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_CONNECTIONS,
ATTR_IDENTIFIERS,
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_SOURCE_BOUQUET, DOMAIN
LOGGER = logging.getLogger(__package__)
class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]):
"""The Enigma2 data update coordinator."""
device: OpenWebIfDevice
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the Enigma2 data update coordinator."""
super().__init__(
hass, logger=LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL
)
base_url = URL.build(
scheme="http" if not config_entry.data[CONF_SSL] else "https",
host=config_entry.data[CONF_HOST],
port=config_entry.data[CONF_PORT],
user=config_entry.data.get(CONF_USERNAME),
password=config_entry.data.get(CONF_PASSWORD),
)
session = async_create_clientsession(
hass, verify_ssl=config_entry.data[CONF_VERIFY_SSL], base_url=base_url
)
self.device = OpenWebIfDevice(
session, source_bouquet=config_entry.options.get(CONF_SOURCE_BOUQUET)
)
self.device_info = DeviceInfo(
configuration_url=base_url,
name=config_entry.data[CONF_HOST],
)
async def _async_setup(self) -> None:
"""Provide needed data to the device info."""
about = await self.device.get_about()
self.device.mac_address = about["info"]["ifaces"][0]["mac"]
self.device_info["model"] = about["info"]["model"]
self.device_info["manufacturer"] = about["info"]["brand"]
self.device_info[ATTR_IDENTIFIERS] = {
(DOMAIN, format_mac(iface["mac"])) for iface in about["info"]["ifaces"]
}
self.device_info[ATTR_CONNECTIONS] = {
(CONNECTION_NETWORK_MAC, format_mac(iface["mac"]))
for iface in about["info"]["ifaces"]
}
async def _async_update_data(self) -> OpenWebIfStatus:
await self.device.update()
return self.device.status

View File

@@ -4,9 +4,9 @@ from __future__ import annotations
import contextlib
from logging import getLogger
from typing import cast
from aiohttp.client_exceptions import ClientConnectorError, ServerDisconnectedError
from openwebif.api import OpenWebIfDevice
from aiohttp.client_exceptions import ServerDisconnectedError
from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption
import voluptuous as vol
@@ -26,11 +26,11 @@ from homeassistant.const import (
CONF_SSL,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import Enigma2ConfigEntry
from .const import (
@@ -49,6 +49,7 @@ from .const import (
DEFAULT_USERNAME,
DOMAIN,
)
from .coordinator import Enigma2UpdateCoordinator
ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording"
ATTR_MEDIA_DESCRIPTION = "media_description"
@@ -107,15 +108,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Enigma2 media player platform."""
device = entry.runtime_data
about = await device.get_about()
device.mac_address = about["info"]["ifaces"][0]["mac"]
entity = Enigma2Device(entry, device, about)
async_add_entities([entity])
async_add_entities([Enigma2Device(entry.runtime_data)])
class Enigma2Device(MediaPlayerEntity):
class Enigma2Device(CoordinatorEntity[Enigma2UpdateCoordinator], MediaPlayerEntity):
"""Representation of an Enigma2 box."""
_attr_has_entity_name = True
@@ -135,118 +131,125 @@ class Enigma2Device(MediaPlayerEntity):
| MediaPlayerEntityFeature.SELECT_SOURCE
)
def __init__(
self, entry: ConfigEntry, device: OpenWebIfDevice, about: dict
) -> None:
def __init__(self, coordinator: Enigma2UpdateCoordinator) -> None:
"""Initialize the Enigma2 device."""
self._device: OpenWebIfDevice = device
self._entry = entry
self._attr_unique_id = device.mac_address or entry.entry_id
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
manufacturer=about["info"]["brand"],
model=about["info"]["model"],
configuration_url=device.base,
name=entry.data[CONF_HOST],
self._attr_unique_id = (
coordinator.device.mac_address
or cast(ConfigEntry, coordinator.config_entry).entry_id
)
self._attr_device_info = coordinator.device_info
async def async_turn_off(self) -> None:
"""Turn off media player."""
if self._device.turn_off_to_deep:
if self.coordinator.device.turn_off_to_deep:
with contextlib.suppress(ServerDisconnectedError):
await self._device.set_powerstate(PowerState.DEEP_STANDBY)
await self.coordinator.device.set_powerstate(PowerState.DEEP_STANDBY)
self._attr_available = False
else:
await self._device.set_powerstate(PowerState.STANDBY)
await self.coordinator.device.set_powerstate(PowerState.STANDBY)
await self.coordinator.async_refresh()
async def async_turn_on(self) -> None:
"""Turn the media player on."""
await self._device.turn_on()
await self.coordinator.device.turn_on()
await self.coordinator.async_refresh()
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
await self._device.set_volume(int(volume * 100))
await self.coordinator.device.set_volume(int(volume * 100))
await self.coordinator.async_refresh()
async def async_volume_up(self) -> None:
"""Volume up the media player."""
await self._device.set_volume(SetVolumeOption.UP)
await self.coordinator.device.set_volume(SetVolumeOption.UP)
await self.coordinator.async_refresh()
async def async_volume_down(self) -> None:
"""Volume down media player."""
await self._device.set_volume(SetVolumeOption.DOWN)
await self.coordinator.device.set_volume(SetVolumeOption.DOWN)
await self.coordinator.async_refresh()
async def async_media_stop(self) -> None:
"""Send stop command."""
await self._device.send_remote_control_action(RemoteControlCodes.STOP)
await self.coordinator.device.send_remote_control_action(
RemoteControlCodes.STOP
)
await self.coordinator.async_refresh()
async def async_media_play(self) -> None:
"""Play media."""
await self._device.send_remote_control_action(RemoteControlCodes.PLAY)
await self.coordinator.device.send_remote_control_action(
RemoteControlCodes.PLAY
)
await self.coordinator.async_refresh()
async def async_media_pause(self) -> None:
"""Pause the media player."""
await self._device.send_remote_control_action(RemoteControlCodes.PAUSE)
await self.coordinator.device.send_remote_control_action(
RemoteControlCodes.PAUSE
)
await self.coordinator.async_refresh()
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_UP)
await self.coordinator.device.send_remote_control_action(
RemoteControlCodes.CHANNEL_UP
)
await self.coordinator.async_refresh()
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
await self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_DOWN)
await self.coordinator.device.send_remote_control_action(
RemoteControlCodes.CHANNEL_DOWN
)
await self.coordinator.async_refresh()
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute."""
if mute != self._device.status.muted:
await self._device.toggle_mute()
if mute != self.coordinator.data.muted:
await self.coordinator.device.toggle_mute()
await self.coordinator.async_refresh()
async def async_select_source(self, source: str) -> None:
"""Select input source."""
await self._device.zap(self._device.sources[source])
await self.coordinator.device.zap(self.coordinator.device.sources[source])
await self.coordinator.async_refresh()
async def async_update(self) -> None:
@callback
def _handle_coordinator_update(self) -> None:
"""Update state of the media_player."""
try:
await self._device.update()
except ClientConnectorError as err:
if self._attr_available:
_LOGGER.warning(
"%s is unavailable. Error: %s", self._device.base.host, err
)
self._attr_available = False
return
if not self._attr_available:
_LOGGER.debug("%s is available", self._device.base.host)
self._attr_available = True
if not self._device.status.in_standby:
if not self.coordinator.data.in_standby:
self._attr_extra_state_attributes = {
ATTR_MEDIA_CURRENTLY_RECORDING: self._device.status.is_recording,
ATTR_MEDIA_DESCRIPTION: self._device.status.currservice.fulldescription,
ATTR_MEDIA_START_TIME: self._device.status.currservice.begin,
ATTR_MEDIA_END_TIME: self._device.status.currservice.end,
ATTR_MEDIA_CURRENTLY_RECORDING: self.coordinator.data.is_recording,
ATTR_MEDIA_DESCRIPTION: self.coordinator.data.currservice.fulldescription,
ATTR_MEDIA_START_TIME: self.coordinator.data.currservice.begin,
ATTR_MEDIA_END_TIME: self.coordinator.data.currservice.end,
}
else:
self._attr_extra_state_attributes = {}
self._attr_media_title = self._device.status.currservice.station
self._attr_media_series_title = self._device.status.currservice.name
self._attr_media_channel = self._device.status.currservice.station
self._attr_is_volume_muted = self._device.status.muted
self._attr_media_content_id = self._device.status.currservice.serviceref
self._attr_media_image_url = self._device.picon_url
self._attr_source = self._device.status.currservice.station
self._attr_source_list = self._device.source_list
self._attr_media_title = self.coordinator.data.currservice.station
self._attr_media_series_title = self.coordinator.data.currservice.name
self._attr_media_channel = self.coordinator.data.currservice.station
self._attr_is_volume_muted = self.coordinator.data.muted
self._attr_media_content_id = self.coordinator.data.currservice.serviceref
self._attr_media_image_url = self.coordinator.device.picon_url
self._attr_source = self.coordinator.data.currservice.station
self._attr_source_list = self.coordinator.device.source_list
if self._device.status.in_standby:
if self.coordinator.data.in_standby:
self._attr_state = MediaPlayerState.OFF
else:
self._attr_state = MediaPlayerState.ON
if (volume_level := self._device.status.volume) is not None:
if (volume_level := self.coordinator.data.volume) is not None:
self._attr_volume_level = volume_level / 100
else:
self._attr_volume_level = None
self.async_write_ha_state()

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from pyenphase import Envoy
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -12,10 +11,10 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.httpx_client import get_async_client
from .const import DOMAIN, PLATFORMS
from .coordinator import EnphaseUpdateCoordinator
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> bool:
"""Set up Enphase Envoy from a config entry."""
host = entry.data[CONF_HOST]
@@ -37,29 +36,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
f"found {envoy.serial_number}"
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> bool:
"""Unload a config entry."""
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator: EnphaseUpdateCoordinator = entry.runtime_data
coordinator.async_cancel_token_refresh()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant, config_entry: EnphaseConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove an enphase_envoy config entry from a device."""
dev_ids = {dev_id[1] for dev_id in device_entry.identifiers if dev_id[0] == DOMAIN}
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
envoy_data = coordinator.envoy.data
envoy_serial_num = config_entry.unique_id
if envoy_serial_num in dev_ids:

View File

@@ -13,14 +13,13 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseUpdateCoordinator
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
@@ -74,11 +73,11 @@ ENPOWER_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: EnphaseConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up envoy binary sensor platform."""
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
envoy_data = coordinator.envoy.data
assert envoy_data is not None
entities: list[BinarySensorEntity] = []

View File

@@ -28,12 +28,17 @@ STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds()
_LOGGER = logging.getLogger(__name__)
type EnphaseConfigEntry = ConfigEntry[EnphaseUpdateCoordinator]
class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""DataUpdateCoordinator to gather data from any envoy."""
envoy_serial_number: str
def __init__(self, hass: HomeAssistant, envoy: Envoy, entry: ConfigEntry) -> None:
def __init__(
self, hass: HomeAssistant, envoy: Envoy, entry: EnphaseConfigEntry
) -> None:
"""Initialize DataUpdateCoordinator for the envoy."""
self.envoy = envoy
entry_data = entry.data

View File

@@ -10,7 +10,6 @@ from pyenphase.envoy import Envoy
from pyenphase.exceptions import EnvoyError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
@@ -23,8 +22,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.json import json_dumps
from homeassistant.util.json import json_loads
from .const import DOMAIN, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES
from .coordinator import EnphaseUpdateCoordinator
from .const import OPTION_DIAGNOSTICS_INCLUDE_FIXTURES
from .coordinator import EnphaseConfigEntry
CONF_TITLE = "title"
CLEAN_TEXT = "<<envoyserial>>"
@@ -81,10 +80,10 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
hass: HomeAssistant, entry: EnphaseConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
if TYPE_CHECKING:
assert coordinator.envoy.data

View File

@@ -16,14 +16,13 @@ from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseUpdateCoordinator
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
@@ -71,11 +70,11 @@ STORAGE_RESERVE_SOC_ENTITY = EnvoyStorageSettingsNumberEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: EnphaseConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Enphase Envoy number platform."""
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
envoy_data = coordinator.envoy.data
assert envoy_data is not None
entities: list[NumberEntity] = []

View File

@@ -12,13 +12,12 @@ from pyenphase.models.dry_contacts import DryContactAction, DryContactMode
from pyenphase.models.tariff import EnvoyStorageMode, EnvoyStorageSettings
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseUpdateCoordinator
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
@@ -126,11 +125,11 @@ STORAGE_MODE_ENTITY = EnvoyStorageSettingsSelectEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: EnphaseConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Enphase Envoy select platform."""
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
envoy_data = coordinator.envoy.data
assert envoy_data is not None
entities: list[SelectEntity] = []

View File

@@ -33,7 +33,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
UnitOfApparentPower,
@@ -50,7 +49,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import EnphaseUpdateCoordinator
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
ICON = "mdi:flash"
@@ -579,11 +578,11 @@ ENCHARGE_AGGREGATE_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: EnphaseConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up envoy sensor platform."""
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
envoy_data = coordinator.envoy.data
assert envoy_data is not None
_LOGGER.debug("Envoy data: %s", envoy_data)

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
import logging
from typing import Any
from pyenphase import Envoy, EnvoyDryContactStatus, EnvoyEnpower
@@ -13,17 +12,14 @@ from pyenphase.models.dry_contacts import DryContactStatus
from pyenphase.models.tariff import EnvoyStorageSettings
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseUpdateCoordinator
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class EnvoyEnpowerSwitchEntityDescription(SwitchEntityDescription):
@@ -78,11 +74,11 @@ CHARGE_FROM_GRID_SWITCH = EnvoyStorageSettingsSwitchEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: EnphaseConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Enphase Envoy switch platform."""
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
envoy_data = coordinator.envoy.data
assert envoy_data is not None
entities: list[SwitchEntity] = []

View File

@@ -45,6 +45,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
"""A fan implementation for ESPHome."""
_supports_speed_levels: bool = True
_enable_turn_on_off_backwards_compatibility = False
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
@@ -148,7 +149,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
api_version = self._api_version
supports_speed_levels = api_version.major == 1 and api_version.minor > 3
self._supports_speed_levels = supports_speed_levels
flags = FanEntityFeature(0)
flags = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
if static_info.supports_oscillation:
flags |= FanEntityFeature.OSCILLATE
if static_info.supports_speed:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from datetime import timedelta
from enum import IntFlag
import functools as ft
@@ -30,6 +31,7 @@ from homeassistant.helpers.deprecation import (
)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.percentage import (
@@ -53,6 +55,8 @@ class FanEntityFeature(IntFlag):
OSCILLATE = 2
DIRECTION = 4
PRESET_MODE = 8
TURN_OFF = 16
TURN_ON = 32
# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
@@ -132,9 +136,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
vol.Optional(ATTR_PRESET_MODE): cv.string,
},
"async_handle_turn_on_service",
[FanEntityFeature.TURN_ON],
)
component.async_register_entity_service(
SERVICE_TURN_OFF, {}, "async_turn_off", [FanEntityFeature.TURN_OFF]
)
component.async_register_entity_service(
SERVICE_TOGGLE,
{},
"async_toggle",
[FanEntityFeature.TURN_OFF, FanEntityFeature.TURN_ON],
)
component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off")
component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle")
component.async_register_entity_service(
SERVICE_INCREASE_SPEED,
{
@@ -228,6 +240,99 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_speed_count: int
_attr_supported_features: FanEntityFeature = FanEntityFeature(0)
__mod_supported_features: FanEntityFeature = FanEntityFeature(0)
# Integrations should set `_enable_turn_on_off_backwards_compatibility` to False
# once migrated and set the feature flags TURN_ON/TURN_OFF as needed.
_enable_turn_on_off_backwards_compatibility: bool = True
def __getattribute__(self, __name: str) -> Any:
"""Get attribute.
Modify return of `supported_features` to
include `_mod_supported_features` if attribute is set.
"""
if __name != "supported_features":
return super().__getattribute__(__name)
# Convert the supported features to ClimateEntityFeature.
# Remove this compatibility shim in 2025.1 or later.
_supported_features: FanEntityFeature = super().__getattribute__(
"supported_features"
)
_mod_supported_features: FanEntityFeature = super().__getattribute__(
"_FanEntity__mod_supported_features"
)
if type(_supported_features) is int: # noqa: E721
_features = FanEntityFeature(_supported_features)
self._report_deprecated_supported_features_values(_features)
else:
_features = _supported_features
if not _mod_supported_features:
return _features
# Add automatically calculated FanEntityFeature.TURN_OFF/TURN_ON to
# supported features and return it
return _features | _mod_supported_features
@callback
def add_to_platform_start(
self,
hass: HomeAssistant,
platform: EntityPlatform,
parallel_updates: asyncio.Semaphore | None,
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
def _report_turn_on_off(feature: str, method: str) -> None:
"""Log warning not implemented turn on/off feature."""
report_issue = self._suggest_report_issue()
message = (
"Entity %s (%s) does not set FanEntityFeature.%s"
" but implements the %s method. Please %s"
)
_LOGGER.warning(
message,
self.entity_id,
type(self),
feature,
method,
report_issue,
)
# Adds FanEntityFeature.TURN_OFF/TURN_ON depending on service calls implemented
# This should be removed in 2025.2.
if self._enable_turn_on_off_backwards_compatibility is False:
# Return if integration has migrated already
return
supported_features = self.supported_features
if supported_features & (FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF):
# The entity supports both turn_on and turn_off, the backwards compatibility
# checks are not needed
return
if not supported_features & FanEntityFeature.TURN_OFF and (
type(self).async_turn_off is not ToggleEntity.async_turn_off
or type(self).turn_off is not ToggleEntity.turn_off
):
# turn_off implicitly supported by implementing turn_off method
_report_turn_on_off("TURN_OFF", "turn_off")
self.__mod_supported_features |= ( # pylint: disable=unused-private-member
FanEntityFeature.TURN_OFF
)
if not supported_features & FanEntityFeature.TURN_ON and (
type(self).async_turn_on is not FanEntity.async_turn_on
or type(self).turn_on is not FanEntity.turn_on
):
# turn_on implicitly supported by implementing turn_on method
_report_turn_on_off("TURN_ON", "turn_on")
self.__mod_supported_features |= ( # pylint: disable=unused-private-member
FanEntityFeature.TURN_ON
)
def set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
raise NotImplementedError
@@ -388,7 +493,7 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def capability_attributes(self) -> dict[str, list[str] | None]:
"""Return capability attributes."""
attrs = {}
supported_features = self.supported_features_compat
supported_features = self.supported_features
if (
FanEntityFeature.SET_SPEED in supported_features
@@ -403,7 +508,7 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def state_attributes(self) -> dict[str, float | str | None]:
"""Return optional state attributes."""
data: dict[str, float | str | None] = {}
supported_features = self.supported_features_compat
supported_features = self.supported_features
if FanEntityFeature.DIRECTION in supported_features:
data[ATTR_DIRECTION] = self.current_direction
@@ -427,19 +532,6 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Flag supported features."""
return self._attr_supported_features
@property
def supported_features_compat(self) -> FanEntityFeature:
"""Return the supported features as FanEntityFeature.
Remove this compatibility shim in 2025.1 or later.
"""
features = self.supported_features
if type(features) is int: # noqa: E721
new_features = FanEntityFeature(features)
self._report_deprecated_supported_features_values(new_features)
return new_features
return features
@cached_property
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., auto, smart, interval, favorite.

View File

@@ -31,6 +31,8 @@ turn_on:
target:
entity:
domain: fan
supported_features:
- fan.FanEntityFeature.TURN_ON
fields:
percentage:
filter:
@@ -53,6 +55,8 @@ turn_off:
target:
entity:
domain: fan
supported_features:
- fan.FanEntityFeature.TURN_OFF
oscillate:
target:

View File

@@ -6,6 +6,7 @@ from calendar import timegm
from datetime import datetime
from logging import getLogger
from time import gmtime, struct_time
from typing import TYPE_CHECKING
from urllib.error import URLError
import feedparser
@@ -120,10 +121,13 @@ class FeedReaderCoordinator(
len(self._feed.entries),
self.url,
)
if not isinstance(self._feed.entries, list):
if not self._feed.entries:
self._log_no_entries()
return None
if TYPE_CHECKING:
assert isinstance(self._feed.entries, list)
self._filter_entries()
self._publish_new_entries()

View File

@@ -29,7 +29,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up event entities for feedreader."""
coordinator: FeedReaderCoordinator = entry.runtime_data
coordinator = entry.runtime_data
async_add_entities([FeedReaderEvent(coordinator)])
@@ -76,8 +76,6 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity):
if content := feed_data.get("content"):
if isinstance(content, list) and isinstance(content[0], dict):
content = content[0].get("value")
else:
content = feed_data.get("summary")
self._trigger_event(
EVENT_FEEDREADER,

View File

@@ -65,7 +65,13 @@ async def async_setup_entry(
class Fan(CoordinatorEntity[FjaraskupanCoordinator], FanEntity):
"""Fan entity."""
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
_enable_turn_on_off_backwards_compatibility = False
_attr_has_entity_name = True
_attr_name = None

View File

@@ -40,6 +40,7 @@ class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntit
_attr_name = None
_attr_is_on = False
_attr_percentage = 0
_enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
@@ -62,8 +63,11 @@ class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntit
model=device["type"],
name=device["name"],
)
self._attr_supported_features = (
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
)
if "rotationSpeed" in self._characteristics:
self._attr_supported_features = FanEntityFeature.SET_SPEED
self._attr_supported_features |= FanEntityFeature.SET_SPEED
@property
def is_on(self) -> bool | None:

View File

@@ -398,7 +398,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
static_paths_configs: list[StaticPathConfig] = []
for path, should_cache in (
("service_worker.js", False),
("sw-modern.js", False),
("sw-modern.js.map", False),
("sw-legacy.js", False),
("sw-legacy.js.map", False),
("robots.txt", False),
("onboarding.html", not is_dev),
("static", not is_dev),

View File

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

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["ha-av==10.1.1", "Pillow==10.3.0"]
"requirements": ["ha-av==10.1.1", "Pillow==10.4.0"]
}

View File

@@ -1,15 +1,12 @@
"""The generic_thermostat component."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device import (
async_remove_stale_devices_links_keep_entity_device,
)
CONF_HEATER = "heater"
DOMAIN = "generic_thermostat"
PLATFORMS = [Platform.CLIMATE]
from .const import CONF_HEATER, PLATFORMS
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View File

@@ -14,13 +14,7 @@ import voluptuous as vol
from homeassistant.components.climate import (
ATTR_PRESET_MODE,
PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA,
PRESET_ACTIVITY,
PRESET_AWAY,
PRESET_COMFORT,
PRESET_ECO,
PRESET_HOME,
PRESET_NONE,
PRESET_SLEEP,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -64,36 +58,31 @@ from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
from . import CONF_HEATER, DOMAIN, PLATFORMS
from .const import (
CONF_AC_MODE,
CONF_COLD_TOLERANCE,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_MIN_DUR,
CONF_PRESETS,
CONF_SENSOR,
DEFAULT_TOLERANCE,
DOMAIN,
PLATFORMS,
)
_LOGGER = logging.getLogger(__name__)
DEFAULT_TOLERANCE = 0.3
DEFAULT_NAME = "Generic Thermostat"
CONF_SENSOR = "target_sensor"
CONF_INITIAL_HVAC_MODE = "initial_hvac_mode"
CONF_KEEP_ALIVE = "keep_alive"
CONF_MIN_TEMP = "min_temp"
CONF_MAX_TEMP = "max_temp"
CONF_TARGET_TEMP = "target_temp"
CONF_AC_MODE = "ac_mode"
CONF_MIN_DUR = "min_cycle_duration"
CONF_COLD_TOLERANCE = "cold_tolerance"
CONF_HOT_TOLERANCE = "hot_tolerance"
CONF_KEEP_ALIVE = "keep_alive"
CONF_INITIAL_HVAC_MODE = "initial_hvac_mode"
CONF_PRECISION = "precision"
CONF_TARGET_TEMP = "target_temp"
CONF_TEMP_STEP = "target_temp_step"
CONF_PRESETS = {
p: f"{p}_temp"
for p in (
PRESET_AWAY,
PRESET_COMFORT,
PRESET_ECO,
PRESET_HOME,
PRESET_SLEEP,
PRESET_ACTIVITY,
)
}
PRESETS_SCHEMA: VolDictType = {
vol.Optional(v): vol.Coerce(float) for v in CONF_PRESETS.values()

View File

@@ -16,7 +16,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
)
from .climate import (
from .const import (
CONF_AC_MODE,
CONF_COLD_TOLERANCE,
CONF_HEATER,

View File

@@ -0,0 +1,34 @@
"""Constants for the Generic Thermostat helper."""
from homeassistant.components.climate import (
PRESET_ACTIVITY,
PRESET_AWAY,
PRESET_COMFORT,
PRESET_ECO,
PRESET_HOME,
PRESET_SLEEP,
)
from homeassistant.const import Platform
DOMAIN = "generic_thermostat"
PLATFORMS = [Platform.CLIMATE]
CONF_AC_MODE = "ac_mode"
CONF_COLD_TOLERANCE = "cold_tolerance"
CONF_HEATER = "heater"
CONF_HOT_TOLERANCE = "hot_tolerance"
CONF_MIN_DUR = "min_cycle_duration"
CONF_PRESETS = {
p: f"{p}_temp"
for p in (
PRESET_AWAY,
PRESET_COMFORT,
PRESET_ECO,
PRESET_HOME,
PRESET_SLEEP,
PRESET_ACTIVITY,
)
}
CONF_SENSOR = "target_sensor"
DEFAULT_TOLERANCE = 0.3

View File

@@ -10,6 +10,8 @@ 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,
ATTR_TEMPERATURE,
@@ -21,23 +23,29 @@ from homeassistant.const import (
Platform,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import (
DOMAIN as HOMEASSISTANT_DOMAIN,
HomeAssistant,
ServiceCall,
callback,
)
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
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
import homeassistant.util.dt as dt_util
_LOGGER = logging.getLogger(__name__)
from .const import DOMAIN
DOMAIN = "geniushub"
_LOGGER = logging.getLogger(__name__)
# temperature is repeated here, as it gives access to high-precision temps
GH_ZONE_ATTRS = ["mode", "temperature", "type", "occupied", "override"]
@@ -54,13 +62,15 @@ SCAN_INTERVAL = timedelta(seconds=60)
MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$"
V1_API_SCHEMA = vol.Schema(
CLOUD_API_SCHEMA = vol.Schema(
{
vol.Required(CONF_TOKEN): cv.string,
vol.Required(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP),
}
)
V3_API_SCHEMA = vol.Schema(
LOCAL_API_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_USERNAME): cv.string,
@@ -68,8 +78,9 @@ V3_API_SCHEMA = vol.Schema(
vol.Optional(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP),
}
)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Any(V3_API_SCHEMA, V1_API_SCHEMA)}, extra=vol.ALLOW_EXTRA
{DOMAIN: vol.Any(LOCAL_API_SCHEMA, CLOUD_API_SCHEMA)}, extra=vol.ALLOW_EXTRA
)
ATTR_ZONE_MODE = "mode"
@@ -106,20 +117,78 @@ PLATFORMS = (
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
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]
async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> bool:
"""Create a Genius Hub system."""
hass.data[DOMAIN] = {}
kwargs = dict(config[DOMAIN])
if CONF_HOST in kwargs:
args = (kwargs.pop(CONF_HOST),)
session = async_get_clientsession(hass)
if CONF_HOST in entry.data:
client = GeniusHub(
entry.data[CONF_HOST],
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
session=session,
)
else:
args = (kwargs.pop(CONF_TOKEN),)
hub_uid = kwargs.pop(CONF_MAC, None)
client = GeniusHub(entry.data[CONF_TOKEN], session=session)
client = GeniusHub(*args, **kwargs, session=async_get_clientsession(hass))
unique_id = entry.unique_id or entry.entry_id
broker = hass.data[DOMAIN]["broker"] = GeniusBroker(hass, client, hub_uid)
broker = entry.runtime_data = GeniusBroker(
hass, client, entry.data.get(CONF_MAC, unique_id)
)
try:
await client.update()
@@ -130,11 +199,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_track_time_interval(hass, broker.async_update, SCAN_INTERVAL)
for platform in PLATFORMS:
hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config))
setup_service_functions(hass, broker)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -175,20 +243,13 @@ def setup_service_functions(hass: HomeAssistant, broker):
class GeniusBroker:
"""Container for geniushub client and data."""
def __init__(
self, hass: HomeAssistant, client: GeniusHub, hub_uid: str | None
) -> None:
def __init__(self, hass: HomeAssistant, client: GeniusHub, hub_uid: str) -> None:
"""Initialize the geniushub client."""
self.hass = hass
self.client = client
self._hub_uid = hub_uid
self.hub_uid = hub_uid
self._connect_error = False
@property
def hub_uid(self) -> str:
"""Return the Hub UID (MAC address)."""
return self._hub_uid if self._hub_uid is not None else self.client.uid
async def async_update(self, now, **kwargs) -> None:
"""Update the geniushub client's data."""
try:

View File

@@ -5,33 +5,27 @@ from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN, GeniusDevice
from . import GeniusDevice, GeniusHubConfigEntry
GH_STATE_ATTR = "outputOnOff"
GH_TYPE = "Receiver"
async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
entry: GeniusHubConfigEntry,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Genius Hub sensor entities."""
if discovery_info is None:
return
"""Set up the Genius Hub binary sensor entities."""
broker = hass.data[DOMAIN]["broker"]
broker = entry.runtime_data
switches = [
async_add_entities(
GeniusBinarySensor(broker, d, GH_STATE_ATTR)
for d in broker.client.device_objs
if GH_TYPE in d.data["type"]
]
async_add_entities(switches, update_before_add=True)
)
class GeniusBinarySensor(GeniusDevice, BinarySensorEntity):

View File

@@ -12,9 +12,8 @@ from homeassistant.components.climate import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN, GeniusHeatingZone
from . import GeniusHeatingZone, GeniusHubConfigEntry
# GeniusHub Zones support: Off, Timer, Override/Boost, Footprint & Linked modes
HA_HVAC_TO_GH = {HVACMode.OFF: "off", HVACMode.HEAT: "timer"}
@@ -26,24 +25,19 @@ GH_PRESET_TO_HA = {v: k for k, v in HA_PRESET_TO_GH.items()}
GH_ZONES = ["radiator", "wet underfloor"]
async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
entry: GeniusHubConfigEntry,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Genius Hub climate entities."""
if discovery_info is None:
return
broker = hass.data[DOMAIN]["broker"]
broker = entry.runtime_data
async_add_entities(
[
GeniusClimateZone(broker, z)
for z in broker.client.zone_objs
if z.data.get("type") in GH_ZONES
]
GeniusClimateZone(broker, z)
for z in broker.client.zone_objs
if z.data.get("type") in GH_ZONES
)

View File

@@ -0,0 +1,136 @@
"""Config flow for Geniushub integration."""
from __future__ import annotations
from http import HTTPStatus
import logging
import socket
from typing import Any
import aiohttp
from geniushubclient import GeniusService
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
_LOGGER = logging.getLogger(__name__)
CLOUD_API_SCHEMA = vol.Schema(
{
vol.Required(CONF_TOKEN): str,
}
)
LOCAL_API_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Geniushub."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""User config step for determine cloud or local."""
return self.async_show_menu(
step_id="user",
menu_options=["local_api", "cloud_api"],
)
async def async_step_local_api(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Version 3 configuration."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{
CONF_HOST: user_input[CONF_HOST],
CONF_USERNAME: user_input[CONF_USERNAME],
}
)
service = GeniusService(
user_input[CONF_HOST],
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
)
try:
response = await service.request("GET", "auth/release")
except socket.gaierror:
errors["base"] = "invalid_host"
except aiohttp.ClientResponseError as err:
if err.status == HTTPStatus.UNAUTHORIZED:
errors["base"] = "invalid_auth"
else:
errors["base"] = "invalid_host"
except (TimeoutError, aiohttp.ClientConnectionError):
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(response["data"]["UID"])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_HOST], data=user_input
)
return self.async_show_form(
step_id="local_api", errors=errors, data_schema=LOCAL_API_SCHEMA
)
async def async_step_cloud_api(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Version 1 configuration."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(user_input)
service = GeniusService(
user_input[CONF_TOKEN], session=async_get_clientsession(self.hass)
)
try:
await service.request("GET", "version")
except aiohttp.ClientResponseError as err:
if err.status == HTTPStatus.UNAUTHORIZED:
errors["base"] = "invalid_auth"
else:
errors["base"] = "invalid_host"
except socket.gaierror:
errors["base"] = "invalid_host"
except (TimeoutError, aiohttp.ClientConnectionError):
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(title="Genius hub", data=user_input)
return self.async_show_form(
step_id="cloud_api", errors=errors, data_schema=CLOUD_API_SCHEMA
)
async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Import the yaml config."""
if CONF_HOST in user_input:
result = await self.async_step_local_api(user_input)
else:
result = await self.async_step_cloud_api(user_input)
if result["type"] is FlowResultType.FORM:
assert result["errors"]
return self.async_abort(reason=result["errors"]["base"])
return result

View File

@@ -0,0 +1,19 @@
"""Constants for Genius Hub."""
from datetime import timedelta
from homeassistant.const import Platform
DOMAIN = "geniushub"
SCAN_INTERVAL = timedelta(seconds=60)
SENSOR_PREFIX = "Genius"
PLATFORMS = (
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.SENSOR,
Platform.SWITCH,
Platform.WATER_HEATER,
)

View File

@@ -2,6 +2,7 @@
"domain": "geniushub",
"name": "Genius Hub",
"codeowners": ["@manzanotti"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/geniushub",
"iot_class": "local_polling",
"loggers": ["geniushubclient"],

View File

@@ -9,10 +9,9 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util
from . import DOMAIN, GeniusDevice, GeniusEntity
from . import GeniusDevice, GeniusEntity, GeniusHubConfigEntry
GH_STATE_ATTR = "batteryLevel"
@@ -23,17 +22,14 @@ GH_LEVEL_MAPPING = {
}
async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
entry: GeniusHubConfigEntry,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Genius Hub sensor entities."""
if discovery_info is None:
return
broker = hass.data[DOMAIN]["broker"]
broker = entry.runtime_data
entities: list[GeniusBattery | GeniusIssue] = [
GeniusBattery(broker, d, GH_STATE_ATTR)
@@ -42,7 +38,7 @@ async def async_setup_platform(
]
entities.extend([GeniusIssue(broker, i) for i in list(GH_LEVEL_MAPPING)])
async_add_entities(entities, update_before_add=True)
async_add_entities(entities)
class GeniusBattery(GeniusDevice, SensorEntity):

View File

@@ -1,4 +1,39 @@
{
"config": {
"step": {
"user": {
"title": "Genius Hub configuration",
"menu_options": {
"local_api": "Local: IP address and user credentials",
"cloud_api": "Cloud: API token"
}
},
"local_api": {
"title": "Genius Hub local configuration",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
}
},
"cloud_api": {
"title": "Genius Hub cloud configuration",
"data": {
"token": "[%key:common::config_flow::data::access_token%]"
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"services": {
"set_zone_mode": {
"name": "Set zone mode",

View File

@@ -11,9 +11,9 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
from homeassistant.helpers.typing import VolDictType
from . import ATTR_DURATION, DOMAIN, GeniusZone
from . import ATTR_DURATION, GeniusHubConfigEntry, GeniusZone
GH_ON_OFF_ZONE = "on / off"
@@ -27,24 +27,19 @@ SET_SWITCH_OVERRIDE_SCHEMA: VolDictType = {
}
async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
entry: GeniusHubConfigEntry,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Genius Hub switch entities."""
if discovery_info is None:
return
broker = hass.data[DOMAIN]["broker"]
broker = entry.runtime_data
async_add_entities(
[
GeniusSwitch(broker, z)
for z in broker.client.zone_objs
if z.data.get("type") == GH_ON_OFF_ZONE
]
GeniusSwitch(broker, z)
for z in broker.client.zone_objs
if z.data.get("type") == GH_ON_OFF_ZONE
)
# Register custom services

View File

@@ -9,9 +9,8 @@ from homeassistant.components.water_heater import (
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN, GeniusHeatingZone
from . import GeniusHeatingZone, GeniusHubConfigEntry
STATE_AUTO = "auto"
STATE_MANUAL = "manual"
@@ -33,24 +32,19 @@ GH_STATE_TO_HA = {
GH_HEATERS = ["hot water temperature"]
async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
entry: GeniusHubConfigEntry,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Genius Hub water_heater entities."""
if discovery_info is None:
return
"""Set up the Genius Hub water heater entities."""
broker = hass.data[DOMAIN]["broker"]
broker = entry.runtime_data
async_add_entities(
[
GeniusWaterHeater(broker, z)
for z in broker.client.zone_objs
if z.data.get("type") in GH_HEATERS
]
GeniusWaterHeater(broker, z)
for z in broker.client.zone_objs
if z.data.get("type") in GH_HEATERS
)

View File

@@ -73,6 +73,14 @@ SUPPORTED_SCHEMA_KEYS = {
def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
"""Format the schema to protobuf."""
if (subschemas := schema.get("anyOf")) or (subschemas := schema.get("allOf")):
for subschema in subschemas: # Gemini API does not support anyOf and allOf keys
if "type" in subschema: # Fallback to first subschema with 'type' field
return _format_schema(subschema)
return _format_schema(
subschemas[0]
) # Or, if not found, to any of the subschemas
result = {}
for key, val in schema.items():
if key not in SUPPORTED_SCHEMA_KEYS:
@@ -81,12 +89,22 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
key = "type_"
val = val.upper()
elif key == "format":
if (schema.get("type") == "string" and val != "enum") or (
schema.get("type") not in ("number", "integer", "string")
):
continue
key = "format_"
elif key == "items":
val = _format_schema(val)
elif key == "properties":
val = {k: _format_schema(v) for k, v in val.items()}
result[key] = val
if result.get("type_") == "OBJECT" and not result.get("properties"):
# An object with undefined properties is not supported by Gemini API.
# Fallback to JSON string. This will probably fail for most tools that want it,
# but we don't have a better fallback strategy so far.
result["properties"] = {"json": {"type_": "STRING"}}
return result

View File

@@ -2,38 +2,40 @@
from __future__ import annotations
from functools import partial
import logging
from govee_ble import GoveeBluetoothDeviceData, SensorUpdate
from govee_ble import GoveeBluetoothDeviceData
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS: list[Platform] = [Platform.SENSOR]
from .coordinator import (
GoveeBLEBluetoothProcessorCoordinator,
GoveeBLEConfigEntry,
process_service_info,
)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
GoveeBLEConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator[SensorUpdate]]
async def async_setup_entry(hass: HomeAssistant, entry: GoveeBLEConfigEntry) -> bool:
"""Set up Govee BLE device from a config entry."""
address = entry.unique_id
assert address is not None
data = GoveeBluetoothDeviceData()
coordinator = PassiveBluetoothProcessorCoordinator(
entry.runtime_data = coordinator = GoveeBLEBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=data.update,
update_method=partial(process_service_info, hass, entry),
device_data=data,
entry=entry,
)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# only start after all platforms have had a chance to subscribe
entry.async_on_unload(coordinator.async_start())

View File

@@ -0,0 +1,104 @@
"""Support for govee-ble binary sensors."""
from __future__ import annotations
from govee_ble import (
BinarySensorDeviceClass as GoveeBLEBinarySensorDeviceClass,
SensorUpdate,
)
from govee_ble.parser import ERROR
from homeassistant import config_entries
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothProcessorEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .coordinator import GoveeBLEPassiveBluetoothDataProcessor
from .device import device_key_to_bluetooth_entity_key
BINARY_SENSOR_DESCRIPTIONS = {
GoveeBLEBinarySensorDeviceClass.WINDOW: BinarySensorEntityDescription(
key=GoveeBLEBinarySensorDeviceClass.WINDOW,
device_class=BinarySensorDeviceClass.WINDOW,
),
}
def sensor_update_to_bluetooth_data_update(
sensor_update: SensorUpdate,
) -> PassiveBluetoothDataUpdate:
"""Convert a sensor update to a bluetooth data update."""
return PassiveBluetoothDataUpdate(
devices={
device_id: sensor_device_info_to_hass_device_info(device_info)
for device_id, device_info in sensor_update.devices.items()
},
entity_descriptions={
device_key_to_bluetooth_entity_key(device_key): BINARY_SENSOR_DESCRIPTIONS[
description.device_class
]
for device_key, description in sensor_update.binary_entity_descriptions.items()
if description.device_class
},
entity_data={
device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value
for device_key, sensor_values in sensor_update.binary_entity_values.items()
},
entity_names={
device_key_to_bluetooth_entity_key(device_key): sensor_values.name
for device_key, sensor_values in sensor_update.binary_entity_values.items()
},
)
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the govee-ble BLE sensors."""
coordinator = entry.runtime_data
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
entry.async_on_unload(
processor.async_add_entities_listener(
GoveeBluetoothBinarySensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, BinarySensorEntityDescription)
)
class GoveeBluetoothBinarySensorEntity(
PassiveBluetoothProcessorEntity[
PassiveBluetoothDataProcessor[bool | None, SensorUpdate]
],
BinarySensorEntity,
):
"""Representation of a govee-ble binary sensor."""
processor: GoveeBLEPassiveBluetoothDataProcessor
@property
def available(self) -> bool:
"""Return False if sensor is in error."""
coordinator = self.processor.coordinator
return self.processor.entity_data.get(self.entity_key) != ERROR and (
((model_info := coordinator.model_info) and model_info.sleepy)
or super().available
)
@property
def is_on(self) -> bool | None:
"""Return the native value."""
return self.processor.entity_data.get(self.entity_key)

View File

@@ -14,7 +14,7 @@ from homeassistant.components.bluetooth import (
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from .const import DOMAIN
from .const import CONF_DEVICE_TYPE, DOMAIN
class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -26,7 +26,9 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_device: DeviceData | None = None
self._discovered_devices: dict[str, str] = {}
self._discovered_devices: dict[
str, tuple[DeviceData, BluetoothServiceInfoBleak]
] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
@@ -51,7 +53,9 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
discovery_info = self._discovery_info
title = device.title or device.get_device_name() or discovery_info.name
if user_input is not None:
return self.async_create_entry(title=title, data={})
return self.async_create_entry(
title=title, data={CONF_DEVICE_TYPE: device.device_type}
)
self._set_confirm_only()
placeholders = {"name": title}
@@ -68,8 +72,10 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
device, service_info = self._discovered_devices[address]
title = device.title or device.get_device_name() or service_info.name
return self.async_create_entry(
title=self._discovered_devices[address], data={}
title=title, data={CONF_DEVICE_TYPE: device.device_type}
)
current_addresses = self._async_current_ids()
@@ -79,9 +85,7 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
continue
device = DeviceData()
if device.supported(discovery_info):
self._discovered_devices[address] = (
device.title or device.get_device_name() or discovery_info.name
)
self._discovered_devices[address] = (device, discovery_info)
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
@@ -89,6 +93,16 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
{
vol.Required(CONF_ADDRESS): vol.In(
{
address: f"{device.get_device_name(None) or discovery_info.name} ({address})"
for address, (
device,
discovery_info,
) in self._discovered_devices.items()
}
)
}
),
)

View File

@@ -1,3 +1,5 @@
"""Constants for the Govee Bluetooth integration."""
DOMAIN = "govee_ble"
CONF_DEVICE_TYPE = "device_type"

View File

@@ -0,0 +1,88 @@
"""The govee Bluetooth integration."""
from collections.abc import Callable
from logging import Logger
from govee_ble import GoveeBluetoothDeviceData, ModelInfo, SensorUpdate, get_model_info
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfoBleak,
)
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import CONF_DEVICE_TYPE, DOMAIN
type GoveeBLEConfigEntry = ConfigEntry[GoveeBLEBluetoothProcessorCoordinator]
def process_service_info(
hass: HomeAssistant,
entry: GoveeBLEConfigEntry,
service_info: BluetoothServiceInfoBleak,
) -> SensorUpdate:
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
coordinator = entry.runtime_data
data = coordinator.device_data
update = data.update(service_info)
if not coordinator.model_info and (device_type := data.device_type):
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_DEVICE_TYPE: device_type}
)
coordinator.set_model_info(device_type)
if update.events and hass.state is CoreState.running:
# Do not fire events on data restore
address = service_info.device.address
for event in update.events.values():
key = event.device_key.key
signal = format_event_dispatcher_name(address, key)
async_dispatcher_send(hass, signal)
return update
def format_event_dispatcher_name(address: str, key: str) -> str:
"""Format an event dispatcher name."""
return f"{DOMAIN}_{address}_{key}"
class GoveeBLEBluetoothProcessorCoordinator(
PassiveBluetoothProcessorCoordinator[SensorUpdate]
):
"""Define a govee ble Bluetooth Passive Update Processor Coordinator."""
def __init__(
self,
hass: HomeAssistant,
logger: Logger,
address: str,
mode: BluetoothScanningMode,
update_method: Callable[[BluetoothServiceInfoBleak], SensorUpdate],
device_data: GoveeBluetoothDeviceData,
entry: ConfigEntry,
) -> None:
"""Initialize the Govee BLE Bluetooth Passive Update Processor Coordinator."""
super().__init__(hass, logger, address, mode, update_method)
self.device_data = device_data
self.entry = entry
self.model_info: ModelInfo | None = None
if device_type := entry.data.get(CONF_DEVICE_TYPE):
self.set_model_info(device_type)
def set_model_info(self, device_type: str) -> None:
"""Set the model info."""
self.model_info = get_model_info(device_type)
class GoveeBLEPassiveBluetoothDataProcessor[_T](
PassiveBluetoothDataProcessor[_T, SensorUpdate]
):
"""Define a govee-ble Bluetooth Passive Update Data Processor."""
coordinator: GoveeBLEBluetoothProcessorCoordinator

View File

@@ -0,0 +1,16 @@
"""Support for govee-ble devices."""
from __future__ import annotations
from govee_ble import DeviceKey
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothEntityKey,
)
def device_key_to_bluetooth_entity_key(
device_key: DeviceKey,
) -> PassiveBluetoothEntityKey:
"""Convert a device key to an entity key."""
return PassiveBluetoothEntityKey(device_key.key, device_key.device_id)

View File

@@ -0,0 +1,107 @@
"""Support for govee_ble event entities."""
from __future__ import annotations
from govee_ble import ModelInfo, SensorType
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_last_service_info,
)
from homeassistant.components.event import (
EventDeviceClass,
EventEntity,
EventEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import GoveeBLEConfigEntry, format_event_dispatcher_name
BUTTON_DESCRIPTIONS = [
EventEntityDescription(
key=f"button_{i}",
translation_key=f"button_{i}",
event_types=["press"],
device_class=EventDeviceClass.BUTTON,
)
for i in range(6)
]
MOTION_DESCRIPTION = EventEntityDescription(
key="motion",
event_types=["motion"],
device_class=EventDeviceClass.MOTION,
)
class GoveeBluetoothEventEntity(EventEntity):
"""Representation of a govee ble event entity."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(
self,
model_info: ModelInfo,
service_info: BluetoothServiceInfoBleak | None,
address: str,
description: EventEntityDescription,
) -> None:
"""Initialise a govee ble event entity."""
self.entity_description = description
# Matches logic in PassiveBluetoothProcessorEntity
name = service_info.name if service_info else model_info.model_id
self._attr_device_info = dr.DeviceInfo(
name=name,
identifiers={(DOMAIN, address)},
connections={(dr.CONNECTION_BLUETOOTH, address)},
)
self._attr_unique_id = f"{address}-{description.key}"
self._address = address
self._signal = format_event_dispatcher_name(
self._address, self.entity_description.key
)
async def async_added_to_hass(self) -> None:
"""Entity added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
self._signal,
self._async_handle_event,
)
)
@callback
def _async_handle_event(self) -> None:
self._trigger_event(self.event_types[0])
self.async_write_ha_state()
async def async_setup_entry(
hass: HomeAssistant,
entry: GoveeBLEConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a govee ble event."""
coordinator = entry.runtime_data
if not (model_info := coordinator.model_info):
return
address = coordinator.address
sensor_type = model_info.sensor_type
if sensor_type is SensorType.MOTION:
descriptions = [MOTION_DESCRIPTION]
elif sensor_type is SensorType.BUTTON:
button_count = model_info.button_count
descriptions = BUTTON_DESCRIPTIONS[0:button_count]
else:
return
last_service_info = async_last_service_info(hass, address, False)
async_add_entities(
GoveeBluetoothEventEntity(model_info, last_service_info, address, description)
for description in descriptions
)

View File

@@ -114,5 +114,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
"iot_class": "local_push",
"requirements": ["govee-ble==0.37.0"]
"requirements": ["govee-ble==0.38.0"]
}

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