Compare commits

..

93 Commits

Author SHA1 Message Date
Martin Hjelmare
f218c37364 Add config flow to remember_the_milk 2025-02-21 21:32:19 +01:00
Martin Hjelmare
d2a660714f Move rememeber the milk config storage to own module 2025-02-21 17:53:26 +01:00
Shay Levy
800f680bd5 Fix Shelly model name for xmod devices (#138984) 2025-02-21 09:53:43 -06:00
Martin Hjelmare
26c60880e4 Add remember the milk entity tests (#138991)
* Add remember the milk entity tests

* Fix docstring
2025-02-21 09:45:00 -06:00
Manu
059a6dddbe Fix off by one bug when sorting tasks in Habitica integration (#138993)
* Fix off-by-one bug when sorting dailies and to-dos in Habitica

* Add test
2025-02-21 09:39:24 -06:00
starkillerOG
0f7cb6b757 Bump reolink-aio to 0.12.0 (#138985) 2025-02-21 16:36:48 +01:00
Manu
8068f82888 Don't fail on successful relogin in pyLoad integration (#138936)
* Don't fail on successful relogin

* logging
2025-02-21 16:16:55 +01:00
Robert Resch
d522571308 Bump deebot-client to 12.2.0 (#138986) 2025-02-21 16:05:14 +01:00
puddly
debee25086 Migrate homeassistant_hardware to use FirmwareInfo instead of just the application type (#138874)
* Migrate `self._probed_firmware_type` to `self._probed_firmware_info`

* Migrate from `firmware_type` to the full `firmware_info`

* Implement `probe_silabs_firmware_type` via `probe_silabs_firmware_info`

* Fix unit tests

* Increase coverage

* Bring test coverage to 100%

* Simplify test per review comment
2025-02-21 09:26:35 -05:00
dependabot[bot]
508b6c8db0 Bump sigstore/cosign-installer from 3.8.0 to 3.8.1 (#138973) 2025-02-21 14:50:21 +01:00
Markus Adrario
97a124b28a Homee: fix state_class of rain sensors. (#138310) 2025-02-21 14:10:45 +01:00
Christopher Fenner
800749728b Extend initial IQS state for ViCare (#138952) 2025-02-21 13:37:08 +01:00
Andrew Sayre
b73c6ed768 Update HEOS host from discovery (#138950) 2025-02-21 13:32:36 +01:00
Pete Sage
1d43cb3f29 Media Player tests patch demo object (#138854) 2025-02-21 13:25:22 +01:00
Sam Wright
56e36cb1ff Bump aiounifi to v82 (#138975) 2025-02-21 13:24:38 +01:00
J. Nick Koston
4f43c971cd Remember inkbird device type in the config entry (#138967) 2025-02-21 13:22:34 +01:00
Jonas Fors Lellky
113e703d5c Mark flexit_bacnet as silver on the quality scale 🥈 (#138951) 2025-02-21 05:31:03 -06:00
Josef Zweck
e59ec8f867 Add ability to get callback when a config entry state changes (#138943)
* Add entry_on_state_change_helper

* undo black

* remove unload

* no coro

* Add tests

* Don't accept coro

* Review feedback

* Add error test

* Make it callback type

* Make it callback type

* Removal test

* change type
2025-02-21 11:55:56 +01:00
puddly
b35d252549 Bump universal-silabs-flasher to v0.0.29 (#138970)
* Bump flasher from 0.0.25 to 0.0.29

* Add new application type
2025-02-20 23:03:26 -05:00
J. Nick Koston
71bdd0e237 Bump inkbird-ble to 0.7.0 (#138964) 2025-02-20 18:53:04 -06:00
proohit
9105542bab Add debug launch configuration for current open test file (#137177) 2025-02-21 00:32:17 +01:00
Diogo Gomes
9cbed483fb Bump pyprosegur to 0.0.13 (#138960) 2025-02-21 00:12:27 +01:00
Luke Hines
c687f37539 Jellyfin - Improve media image quality (#138958) 2025-02-20 22:56:37 +00:00
Josh Gustafson
97b853e2ea Bump arcam-fmj to 1.8.1 (#138959) 2025-02-21 00:16:25 +02:00
epenet
9d241a77b7 Adjust DSL line status options in SFR Box integration (#136425) 2025-02-20 23:14:17 +01:00
cro
1cae504cfe Fix bug in set_preset_mode_with_end_datetime (wrong typo of frost_guard) (#138402) 2025-02-20 22:52:03 +01:00
Petr V
509add8e5c Adjust Tuya Water Detector to support 1 as an alarm state (#135933) 2025-02-20 22:51:49 +01:00
J. Nick Koston
97bf557b32 Restore PaddleSwitchPico (Pico Paddle Remote) device trigger to Lutron Caseta (#137689) 2025-02-20 22:49:26 +01:00
Norbert Rittel
aec7fc1835 Use capitalized "Modbus" as name, replace "slave" with "server" (#138945) 2025-02-20 22:42:29 +01:00
J. Nick Koston
ab299d2bf7 Bump propcache to 0.3.0 (#138949) 2025-02-20 22:39:33 +01:00
Michael
490e012e54 Fix handling of min/max temperature presets in AVM Fritz!SmartHome (#138954) 2025-02-20 22:38:43 +01:00
Arie Catsman
e8ff31b792 Add error handling to enphase_envoy number platform action (#136812) 2025-02-20 22:23:59 +01:00
Franck Nijhof
5f98d5a65a Revert Python 3.13.2 requirement for now (#138948) 2025-02-20 19:42:11 +01:00
Markus Adrario
5d1eb69281 Add light platform to Homee (#138776) 2025-02-20 19:31:31 +01:00
Norbert Rittel
ec7ec993b0 Improve names and descriptions of media_player.xxx_set actions (#138773)
Co-authored-by: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com>
2025-02-20 18:26:14 +01:00
Erik Montnemery
ff4f4111d0 Minor adjustment of recorder helper (#138941) 2025-02-20 16:28:39 +00:00
Jonas Fors Lellky
66f293c8f3 Add climate entity tests for flexit_bacnet and mark test coverage done (99%) (#138887) 2025-02-20 16:30:50 +01:00
Joost Lekkerkerker
8826714704 Bump ruff to 0.9.7 (#138939) 2025-02-20 16:23:21 +01:00
Erik Montnemery
f828b4e0b9 Adjust config entry state check in vizio (#138905) 2025-02-20 16:18:57 +01:00
Steven Stallion
73442e8443 Add SensorPush Cloud integration (#134223) 2025-02-20 16:15:47 +01:00
Erik Montnemery
0d8c449ff4 Validate hassio backup settings (#138880)
* Validate hassio backup settings

* Add snapshots

* Don't reset addon and folder settings

* Adapt to changes in BackupConfig.update
2025-02-20 16:06:33 +01:00
Erik Montnemery
fb57284561 Remove helper.recorder.async_wait_recorder (#138935) 2025-02-20 15:02:22 +00:00
Dmitry Kuzmenko
b856de225d Catch zeep fault as well on GetSystemDateAndTime call. (#138916) 2025-02-20 15:18:19 +01:00
Josef Zweck
9f7c4648a2 Allow files to be directly deleted in onedrive (#138908)
* Allow files to be directly deleted in onedrive

* let options flow reload

* update description
2025-02-20 13:35:29 +01:00
Andrew Sayre
2d0967994e Fix ability to set HEOS options (#138235) 2025-02-20 13:14:57 +01:00
J. Nick Koston
d2bd45099b Bump habluetooth to 3.22.1 and bleak-retry-connector to 3.9.0 (#138898) 2025-02-20 13:11:14 +01:00
Erik Montnemery
6d6dfce7d1 Adjust cleanup of removed integration spider (#138932) 2025-02-20 12:19:00 +01:00
Erik Montnemery
d9a18c2994 Adjust cleanup of removed integration myq (#138931) 2025-02-20 12:18:40 +01:00
Erik Montnemery
affec21a6a Adjust cleanup of removed integration mazda (#138930) 2025-02-20 12:17:58 +01:00
Erik Montnemery
94869f3210 Adjust cleanup of removed integration linear_garage_door (#138929) 2025-02-20 12:17:10 +01:00
Erik Montnemery
e53617a788 Adjust cleanup of removed integration life360 (#138928) 2025-02-20 12:16:39 +01:00
Erik Montnemery
e916b57714 Adjust cleanup of removed integration eight_sleep (#138926) 2025-02-20 12:16:23 +01:00
Martin Hjelmare
119b296c26 Make backup config update a callback (#138925) 2025-02-20 11:11:34 +00:00
Markus Adrario
20f273f06a Add button platform to Homee (#138923) 2025-02-20 12:07:12 +01:00
Jan-Philipp Benecke
6aae319b1a Allow use of insecure ciphers in rest_command (#138886) 2025-02-20 10:48:45 +01:00
J. Nick Koston
b3e245687c Bump bluetooth-auto-recovery to 1.4.4 (#138895) 2025-02-20 10:48:01 +01:00
starkillerOG
1a56dcfdaf Fix Reolink callback id collision (#138918) 2025-02-20 10:46:24 +01:00
Norbert Rittel
66af5ca1e9 Improve action descriptions of ness_alarm integration (#138921)
- for the panic action change the description to "Triggers a panic _alarm_" as we don't want to trigger a panic ;-)
- for the aux action replace "Trigger …" with "Changes the state of an aux output" as it can turn this off as well
- clarify the description of the state field, dropping "true" for a UI-friendly wording
2025-02-20 10:04:05 +01:00
Erik Montnemery
d24a14442f Adjust cleanup of removed integration aladdin_connect (#138917) 2025-02-20 09:38:15 +01:00
Erik Montnemery
c7169a4ed7 Adjust config entry state checks in nest (#138912) 2025-02-20 09:14:45 +01:00
Erik Montnemery
08358514b4 Adjust config entry state checks in mcp_server (#138913) 2025-02-20 09:14:17 +01:00
Erik Montnemery
1392bab4d5 Adjust config entry state checks in renault (#138910) 2025-02-20 09:11:15 +01:00
Erik Montnemery
e79a1a52c3 Adjust config entry state checks in esphome (#138914) 2025-02-20 09:08:46 +01:00
dependabot[bot]
872cca9935 Bump actions/cache from 4.2.0 to 4.2.1 (#138901) 2025-02-20 09:03:54 +01:00
Erik Montnemery
1bf7e5d749 Adjust config entry state check in yolink (#138904) 2025-02-20 09:01:15 +01:00
Erik Montnemery
2f7a8b4d9d Adjust config entry state checks in reolink (#138909) 2025-02-20 08:58:37 +01:00
Erik Montnemery
0949f7d0ba Adjust config entry state checks in qbus (#138911) 2025-02-20 08:57:55 +01:00
dependabot[bot]
a2ceeb19dc Bump docker/build-push-action from 6.13.0 to 6.14.0 (#138902) 2025-02-20 08:47:37 +01:00
Erik Montnemery
1c3d6b5641 Minor readability improvement of Spotify browse media (#138907) 2025-02-20 08:45:36 +01:00
Saswat Padhi
14375e76a3 Opower: Fix unavailable "start date" and "end date" sensors (#138694)
avoid passing string into date device class
2025-02-19 23:42:09 -08:00
Manu
e5c0183e0f Set parallel_updates in pyLoad integration (#138897)
Set parallel_updates
2025-02-20 08:15:14 +01:00
Manu
5c8fa717bf Move test before setup coordinator _async_setup in pyLoad integration (#138893)
Move setup test to `async_setup` in the coordinator
2025-02-20 08:14:08 +01:00
Thomas D
5d851b6a56 Add light platform to qbus (#136168)
* Add light platform

* Add on/off for light

* Renamed add_entities to async_add_entities

* Revert qbusmqttapi bump

* Align dependency version

* Use AddConfigEntryEntitiesCallback

* Use AddConfigEntryEntitiesCallback
2025-02-20 06:13:13 +01:00
Manu
5dfd358fc9 Bump pyloadapi to 1.4.1 (#138894) 2025-02-19 20:51:13 -06:00
Simone Chemelli
901011de7b Use xmod model info for Shelly XMOD devices (#137013) 2025-02-19 22:47:23 +01:00
Erik Montnemery
ad7780291e Correct backup date when reading a backup created by supervisor (#138860) 2025-02-19 22:40:03 +01:00
Simone Chemelli
eb6993f0a8 Switch cleanup for Shelly (part 1) (#138791) 2025-02-19 22:39:17 +01:00
Glenn Waters
406f894dc1 Environment Canada: Add a detailed forecast action (#138806)
* Add forecast service.

* Add detailed Environment Canada forecast data.

* Add icon and translations.

* Fix missing commas

* Add const.

* Add test.
2025-02-19 15:07:53 -06:00
Franck Nijhof
0a0a96fb3b Add initial basic GitHub Copilot instructions (#137754)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-02-19 21:52:20 +01:00
Erik Montnemery
354855ff5f Remove some dead code from the conversation integration (#138878) 2025-02-19 21:51:45 +01:00
Abílio Costa
8e6f2e6ff2 Add LINAK virtual integration supported by Idasen Desk (#138749) 2025-02-19 21:48:27 +01:00
Paulus Schoutsen
0b6f49fec2 Filter out certain intents from being matched in local fallback (#137763)
* Filter out certain intents from being matched in local fallback

* Only filter if LLM agent can control HA
2025-02-19 14:27:42 -06:00
Michael
b2e2ef3119 Bump pyfritzhome to 0.6.15 (#138879) 2025-02-19 21:24:35 +01:00
Norbert Rittel
e360348525 Make description of input_select.select_next action consistent (#138877) 2025-02-19 20:28:09 +01:00
Steven Hartland
4ed4c2cc5c Fix scaffolding generations (#138820) 2025-02-19 20:23:29 +01:00
Norbert Rittel
bc5146db3c Make field description of snips.say_action UI-friendly (#138276) 2025-02-19 20:21:30 +01:00
Maghiel Dijksman
f98e83514d Tuya camera rm duplication (#138794) 2025-02-19 20:03:32 +01:00
Norbert Rittel
e847a8d6a5 Capitalize all occurrences of "Bond" brand name (#138876)
Also makes older action descriptions consistent.
2025-02-19 20:49:30 +02:00
Artur Pragacz
7117708937 Improve reading clarity of steps code in scripts helper (#134395)
* Reorganize steps code in scripts helper

* Address feedback

* Revert to getattr
2025-02-19 19:37:36 +01:00
Josef Zweck
d2ce89882b Bump onedrive-personal-sdk to 0.0.11 (#138861) 2025-02-19 11:52:38 -06:00
Andrew Sayre
1d3fcc67b8 Select preferred discovered HEOS host (#138779)
* Select preffered host from discovery

* Remove invalid test comment
2025-02-19 11:51:47 -06:00
Jonas Fors Lellky
32b854515b Add exception translation for async_set_temperature in integration flexit_bacnet (#138870) 2025-02-19 18:23:58 +01:00
Artur Pragacz
6c3a9cb1a8 Improve reading clarity of steps code in scripts helper part 1 (#138628) 2025-02-19 11:18:28 -06:00
224 changed files with 9605 additions and 1656 deletions

100
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,100 @@
# Instructions for GitHub Copilot
This repository holds the core of Home Assistant, a Python 3 based home
automation application.
- Python code must be compatible with Python 3.13
- Use the newest Python language features if possible:
- Pattern matching
- Type hints
- f-strings for string formatting over `%` or `.format()`
- Dataclasses
- Walrus operator
- Code quality tools:
- Formatting: Ruff
- Linting: PyLint and Ruff
- Type checking: MyPy
- Testing: pytest with plain functions and fixtures
- Inline code documentation:
- File headers should be short and concise:
```python
"""Integration for Peblar EV chargers."""
```
- Every method and function needs a docstring:
```python
async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool:
"""Set up Peblar from a config entry."""
...
```
- All code and comments and other text are written in American English
- Follow existing code style patterns as much as possible
- Core locations:
- Shared constants: `homeassistant/const.py`, use them instead of hardcoding
strings or creating duplicate integration constants.
- Integration files:
- Constants: `homeassistant/components/{domain}/const.py`
- Models: `homeassistant/components/{domain}/models.py`
- Coordinator: `homeassistant/components/{domain}/coordinator.py`
- Config flow: `homeassistant/components/{domain}/config_flow.py`
- Platform code: `homeassistant/components/{domain}/{platform}.py`
- All external I/O operations must be async
- Async patterns:
- Avoid sleeping in loops
- Avoid awaiting in loops, gather instead
- No blocking calls
- Polling:
- Follow update coordinator pattern, when possible
- Polling interval may not be configurable by the user
- For local network polling, the minimum interval is 5 seconds
- For cloud polling, the minimum interval is 60 seconds
- Error handling:
- Use specific exceptions from `homeassistant.exceptions`
- Setup failures:
- Temporary: Raise `ConfigEntryNotReady`
- Permanent: Use `ConfigEntryError`
- Logging:
- Message format:
- No periods at end
- No integration names or domains (added automatically)
- No sensitive data (keys, tokens, passwords), even when those are incorrect.
- Be very restrictive on the use of logging info messages, use debug for
anything which is not targeting the user.
- Use lazy logging (no f-strings):
```python
_LOGGER.debug("This is a log message with %s", variable)
```
- Entities:
- Ensure unique IDs for state persistence:
- Unique IDs should not contain values that are subject to user or network change.
- An ID needs to be unique per platform, not per integration.
- The ID does not have to contain the integration domain or platform.
- Acceptable examples:
- Serial number of a device
- MAC address of a device formatted using `homeassistant.helpers.device_registry.format_mac`
Do not obtain the MAC address through arp cache of local network access,
only use the MAC address provided by discovery or the device itself.
- Unique identifier that is physically printed on the device or burned into an EEPROM
- Not acceptable examples:
- IP Address
- Device name
- Hostname
- URL
- Email address
- Username
- For entities that are setup by a config entry, the config entry ID
can be used as a last resort if no other Unique ID is available.
For example: `f"{entry.entry_id}-battery"`
- If the state value is unknown, use `None`
- Do not use the `unavailable` string as a state value,
implement the `available()` property method instead
- Do not use the `unknown` string as a state value, use `None` instead
- Extra entity state attributes:
- The keys of all state attributes should always be present
- If the value is unknown, use `None`
- Provide descriptive state attributes
- Testing:
- Test location: `tests/components/{domain}/`
- Use pytest fixtures from `tests.common`
- Mock external dependencies
- Use snapshots for complex data
- Follow existing test patterns

View File

@@ -324,7 +324,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Install Cosign
uses: sigstore/cosign-installer@v3.8.0
uses: sigstore/cosign-installer@v3.8.1
with:
cosign-release: "v2.2.3"
@@ -509,7 +509,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -522,7 +522,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile

View File

@@ -240,7 +240,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.0
uses: actions/cache@v4.2.1
with:
path: venv
key: >-
@@ -256,7 +256,7 @@ jobs:
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.2.0
uses: actions/cache@v4.2.1
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
@@ -286,7 +286,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -295,7 +295,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -326,7 +326,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -335,7 +335,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -366,7 +366,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -375,7 +375,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -482,7 +482,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.0
uses: actions/cache@v4.2.1
with:
path: venv
key: >-
@@ -490,7 +490,7 @@ jobs:
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@v4.2.0
uses: actions/cache@v4.2.1
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -578,7 +578,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -611,7 +611,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -649,7 +649,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -692,7 +692,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -739,7 +739,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -791,7 +791,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -799,7 +799,7 @@ jobs:
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
uses: actions/cache@v4.2.0
uses: actions/cache@v4.2.1
with:
path: .mypy_cache
key: >-
@@ -865,7 +865,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -929,7 +929,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -1051,7 +1051,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -1181,7 +1181,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true
@@ -1328,7 +1328,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.0
uses: actions/cache/restore@v4.2.1
with:
path: venv
fail-on-cache-miss: true

View File

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

View File

@@ -438,6 +438,7 @@ homeassistant.components.select.*
homeassistant.components.sensibo.*
homeassistant.components.sensirion_ble.*
homeassistant.components.sensor.*
homeassistant.components.sensorpush_cloud.*
homeassistant.components.sensoterra.*
homeassistant.components.senz.*
homeassistant.components.sfr_box.*

10
.vscode/launch.json vendored
View File

@@ -42,6 +42,14 @@
"--picked"
],
},
{
"name": "Home Assistant: Debug Current Test File",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"console": "integratedTerminal",
"args": ["-vv", "${file}"]
},
{
// Debug by attaching to local Home Assistant server using Remote Python Debugger.
// See https://www.home-assistant.io/integrations/debugpy/
@@ -77,4 +85,4 @@
]
}
]
}
}

2
CODEOWNERS generated
View File

@@ -1342,6 +1342,8 @@ build.json @home-assistant/supervisor
/tests/components/sensorpro/ @bdraco
/homeassistant/components/sensorpush/ @bdraco
/tests/components/sensorpush/ @bdraco
/homeassistant/components/sensorpush_cloud/ @sstallion
/tests/components/sensorpush_cloud/ @sstallion
/homeassistant/components/sensoterra/ @markruys
/tests/components/sensoterra/ @markruys
/homeassistant/components/sentry/ @dcramer @frenck

View File

@@ -0,0 +1,5 @@
{
"domain": "sensorpush",
"name": "SensorPush",
"integrations": ["sensorpush", "sensorpush_cloud"]
}

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -28,11 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/arcam_fmj",
"iot_class": "local_polling",
"loggers": ["arcam"],
"requirements": ["arcam-fmj==1.8.0"],
"requirements": ["arcam-fmj==1.8.1"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -13,7 +13,7 @@ from pathlib import Path
from queue import Empty, Queue
from threading import Thread
import time
from typing import Any, Literal, cast
from typing import TYPE_CHECKING, Any, Literal, cast
import wave
import hass_nabucasa
@@ -30,7 +30,7 @@ from homeassistant.components import (
from homeassistant.components.tts import (
generate_media_source_id as tts_generate_media_source_id,
)
from homeassistant.const import MATCH_ALL
from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import chat_session, intent
@@ -81,6 +81,9 @@ from .error import (
)
from .vad import AudioBuffer, VoiceActivityTimeout, VoiceCommandSegmenter, chunk_samples
if TYPE_CHECKING:
from hassil.recognize import RecognizeResult
_LOGGER = logging.getLogger(__name__)
STORAGE_KEY = f"{DOMAIN}.pipelines"
@@ -123,6 +126,12 @@ STORED_PIPELINE_RUNS = 10
SAVE_DELAY = 10
@callback
def _async_local_fallback_intent_filter(result: RecognizeResult) -> bool:
"""Filter out intents that are not local fallback."""
return result.intent.name in (intent.INTENT_GET_STATE, intent.INTENT_NEVERMIND)
@callback
def _async_resolve_default_pipeline_settings(
hass: HomeAssistant,
@@ -1084,10 +1093,22 @@ class PipelineRun:
)
intent_response.async_set_speech(trigger_response_text)
intent_filter: Callable[[RecognizeResult], bool] | None = None
# If the LLM has API access, we filter out some sentences that are
# interfering with LLM operation.
if (
intent_agent_state := self.hass.states.get(self.intent_agent)
) and intent_agent_state.attributes.get(
ATTR_SUPPORTED_FEATURES, 0
) & conversation.ConversationEntityFeature.CONTROL:
intent_filter = _async_local_fallback_intent_filter
# Try local intents first, if preferred.
elif self.pipeline.prefer_local_intents and (
intent_response := await conversation.async_handle_intents(
self.hass, user_input
self.hass,
user_input,
intent_filter=intent_filter,
)
):
# Local intent matched

View File

@@ -16,7 +16,7 @@ from .agent import (
BackupAgentPlatformProtocol,
LocalBackupAgent,
)
from .config import BackupConfig
from .config import BackupConfig, CreateBackupParametersDict
from .const import DATA_MANAGER, DOMAIN
from .http import async_register_http_views
from .manager import (
@@ -55,6 +55,7 @@ __all__ = [
"BackupReaderWriter",
"BackupReaderWriterError",
"CreateBackupEvent",
"CreateBackupParametersDict",
"CreateBackupStage",
"CreateBackupState",
"Folder",

View File

@@ -154,7 +154,8 @@ class BackupConfig:
self.data.retention.apply(self._manager)
self.data.schedule.apply(self._manager)
async def update(
@callback
def update(
self,
*,
agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,

View File

@@ -1870,7 +1870,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
and "hassio.local" in create_backup.agent_ids
):
automatic_agents = [self._local_agent_id, *automatic_agents]
await config.update(
config.update(
create_backup=CreateBackupParametersDict(
agent_ids=automatic_agents,
include_addons=None,

View File

@@ -104,12 +104,15 @@ def read_backup(backup_path: Path) -> AgentBackup:
bool, homeassistant.get("exclude_database", False)
)
extra_metadata = cast(dict[str, bool | str], data.get("extra", {}))
date = extra_metadata.get("supervisor.backup_request_date", data["date"])
return AgentBackup(
addons=addons,
backup_id=cast(str, data["slug"]),
database_included=database_included,
date=cast(str, data["date"]),
extra_metadata=cast(dict[str, bool | str], data.get("extra", {})),
date=cast(str, date),
extra_metadata=extra_metadata,
folders=folders,
homeassistant_included=homeassistant_included,
homeassistant_version=homeassistant_version,

View File

@@ -346,6 +346,7 @@ async def handle_config_info(
)
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
@@ -387,8 +388,7 @@ async def handle_config_info(
),
}
)
@websocket_api.async_response
async def handle_config_update(
def handle_config_update(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
@@ -398,7 +398,7 @@ async def handle_config_update(
changes = dict(msg)
changes.pop("id")
changes.pop("type")
await manager.config.update(**changes)
manager.config.update(**changes)
connection.send_result(msg["id"])

View File

@@ -16,11 +16,11 @@
"quality_scale": "internal",
"requirements": [
"bleak==0.22.3",
"bleak-retry-connector==3.8.1",
"bleak-retry-connector==3.9.0",
"bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.4.2",
"bluetooth-auto-recovery==1.4.4",
"bluetooth-data-tools==1.23.4",
"dbus-fast==2.33.0",
"habluetooth==3.22.0"
"habluetooth==3.22.1"
]
}

View File

@@ -31,7 +31,7 @@
"services": {
"set_fan_speed_tracked_state": {
"name": "Set fan speed tracked state",
"description": "Sets the tracked fan speed for a bond fan.",
"description": "Sets the tracked fan speed for a Bond fan.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -45,7 +45,7 @@
},
"set_switch_power_tracked_state": {
"name": "Set switch power tracked state",
"description": "Sets the tracked power state of a bond switch.",
"description": "Sets the tracked power state of a Bond switch.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -59,7 +59,7 @@
},
"set_light_power_tracked_state": {
"name": "Set light power tracked state",
"description": "Sets the tracked power state of a bond light.",
"description": "Sets the tracked power state of a Bond light.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -73,7 +73,7 @@
},
"set_light_brightness_tracked_state": {
"name": "Set light brightness tracked state",
"description": "Sets the tracked brightness state of a bond light.",
"description": "Sets the tracked brightness state of a Bond light.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -87,15 +87,15 @@
},
"start_increasing_brightness": {
"name": "Start increasing brightness",
"description": "Start increasing the brightness of the light. (deprecated)."
"description": "Starts increasing the brightness of the light (deprecated)."
},
"start_decreasing_brightness": {
"name": "Start decreasing brightness",
"description": "Start decreasing the brightness of the light. (deprecated)."
"description": "Starts decreasing the brightness of the light (deprecated)."
},
"stop": {
"name": "[%key:common::action::stop%]",
"description": "Stop any in-progress action and empty the queue. (deprecated)."
"description": "Stops any in-progress action and empty the queue (deprecated)."
}
}
}

View File

@@ -2,10 +2,12 @@
from __future__ import annotations
from collections.abc import Callable
import logging
import re
from typing import Literal
from hassil.recognize import RecognizeResult
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -241,7 +243,10 @@ async def async_handle_sentence_triggers(
async def async_handle_intents(
hass: HomeAssistant, user_input: ConversationInput
hass: HomeAssistant,
user_input: ConversationInput,
*,
intent_filter: Callable[[RecognizeResult], bool] | None = None,
) -> intent.IntentResponse | None:
"""Try to match input against registered intents and return response.
@@ -250,7 +255,9 @@ async def async_handle_intents(
default_agent = async_get_agent(hass)
assert isinstance(default_agent, DefaultAgent)
return await default_agent.async_handle_intents(user_input)
return await default_agent.async_handle_intents(
user_input, intent_filter=intent_filter
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:

View File

@@ -185,21 +185,6 @@ class IntentCache:
self.cache.clear()
def _get_language_variations(language: str) -> Iterable[str]:
"""Generate language codes with and without region."""
yield language
parts = re.split(r"([-_])", language)
if len(parts) == 3:
lang, sep, region = parts
if sep == "_":
# en_US -> en-US
yield f"{lang}-{region}"
# en-US -> en
yield lang
async def async_setup_default_agent(
hass: core.HomeAssistant,
entity_component: EntityComponent[ConversationEntity],
@@ -1324,6 +1309,8 @@ class DefaultAgent(ConversationEntity):
async def async_handle_intents(
self,
user_input: ConversationInput,
*,
intent_filter: Callable[[RecognizeResult], bool] | None = None,
) -> intent.IntentResponse | None:
"""Try to match sentence against registered intents and return response.
@@ -1331,7 +1318,9 @@ class DefaultAgent(ConversationEntity):
Returns None if no match or a matching error occurred.
"""
result = await self.async_recognize_intent(user_input, strict_intents_only=True)
if not isinstance(result, RecognizeResult):
if not isinstance(result, RecognizeResult) or (
intent_filter is not None and intent_filter(result)
):
# No error message on failed match
return None

View File

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

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -28,11 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))

View File

@@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
from .entity import EnvoyBaseEntity, exception_handler
PARALLEL_UPDATES = 1
@@ -132,6 +132,7 @@ class EnvoyRelayNumberEntity(EnvoyBaseEntity, NumberEntity):
self.data.dry_contact_settings[self._relay_id]
)
@exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Update the relay."""
await self.envoy.update_dry_contact(
@@ -185,6 +186,7 @@ class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity):
assert self.data.tariff.storage_settings is not None
return self.entity_description.value_fn(self.data.tariff.storage_settings)
@exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Update the storage setting."""
await self.entity_description.update_fn(self.envoy, value)

View File

@@ -5,3 +5,4 @@ ATTR_STATION = "station"
CONF_STATION = "station"
CONF_TITLE = "title"
DOMAIN = "environment_canada"
SERVICE_ENVIRONMENT_CANADA_FORECASTS = "get_forecasts"

View File

@@ -21,6 +21,9 @@
"services": {
"set_radar_type": {
"service": "mdi:radar"
},
"get_forecasts": {
"service": "mdi:weather-cloudy-clock"
}
}
}

View File

@@ -1,3 +1,9 @@
get_forecasts:
target:
entity:
integration: environment_canada
domain: weather
set_radar_type:
target:
entity:

View File

@@ -113,6 +113,10 @@
}
},
"services": {
"get_forecasts": {
"name": "Get forecasts",
"description": "Retrieves the forecast from selected weather services."
},
"set_radar_type": {
"name": "Set radar type",
"description": "Sets the type of radar image to retrieve.",

View File

@@ -35,11 +35,16 @@ from homeassistant.const import (
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.core import (
HomeAssistant,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.helpers import entity_platform, entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .const import DOMAIN, SERVICE_ENVIRONMENT_CANADA_FORECASTS
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator
# Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/
@@ -78,6 +83,14 @@ async def async_setup_entry(
async_add_entities([ECWeatherEntity(config_entry.runtime_data.weather_coordinator)])
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_ENVIRONMENT_CANADA_FORECASTS,
None,
"_async_environment_canada_forecasts",
supports_response=SupportsResponse.ONLY,
)
def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> str:
"""Calculate unique ID."""
@@ -185,6 +198,23 @@ class ECWeatherEntity(
"""Return the hourly forecast in native units."""
return get_forecast(self.ec_data, True)
def _async_environment_canada_forecasts(self) -> ServiceResponse:
"""Return the native Environment Canada forecast."""
daily = []
for f in self.ec_data.daily_forecasts:
day = f.copy()
day["timestamp"] = day["timestamp"].isoformat()
daily.append(day)
hourly = []
for f in self.ec_data.hourly_forecasts:
hour = f.copy()
hour["timestamp"] = hour["period"].isoformat()
del hour["period"]
hourly.append(hour)
return {"daily_forecast": daily, "hourly_forecast": hourly}
def get_forecast(ec_data, hourly) -> list[Forecast] | None:
"""Build the forecast array."""

View File

@@ -6,7 +6,7 @@ import asyncio
import logging
from typing import Any
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -108,8 +108,7 @@ class ESPHomeDashboardManager:
reloads = [
hass.config_entries.async_reload(entry.entry_id)
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.state is ConfigEntryState.LOADED
for entry in hass.config_entries.async_loaded_entries(DOMAIN)
]
# Re-auth flows will check the dashboard for encryption key when the form is requested
# but we only trigger reauth if the dashboard is available.

View File

@@ -111,7 +111,13 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
else:
await self.device.set_air_temp_setpoint_home(temperature)
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
raise HomeAssistantError from exc
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_temperature",
translation_placeholders={
"temperature": str(temperature),
},
) from exc
finally:
await self.coordinator.async_refresh()

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/flexit_bacnet",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["flexit_bacnet==2.2.3"]
}

View File

@@ -52,7 +52,7 @@ rules:
status: exempt
comment: |
Integration doesn't require any form of authentication.
test-coverage: todo
test-coverage: done
# Gold
entity-translations: done
entity-device-class: done

View File

@@ -130,6 +130,9 @@
"set_preset_mode": {
"message": "Failed to set preset mode {preset}."
},
"set_temperature": {
"message": "Failed to set temperature {temperature}."
},
"set_hvac_mode": {
"message": "Failed to set HVAC mode {mode}."
},

View File

@@ -85,6 +85,8 @@ async def async_setup_entry(
class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
"""The thermostat class for FRITZ!SmartHome thermostats."""
_attr_max_temp = MAX_TEMPERATURE
_attr_min_temp = MIN_TEMPERATURE
_attr_precision = PRECISION_HALVES
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = "thermostat"
@@ -135,11 +137,13 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
target_temp = kwargs.get(ATTR_TEMPERATURE)
hvac_mode = kwargs.get(ATTR_HVAC_MODE)
if hvac_mode == HVACMode.OFF:
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is HVACMode.OFF:
await self.async_set_hvac_mode(hvac_mode)
elif target_temp is not None:
elif (target_temp := kwargs.get(ATTR_TEMPERATURE)) is not None:
if target_temp == OFF_API_TEMPERATURE:
target_temp = OFF_REPORT_SET_TEMPERATURE
elif target_temp == ON_API_TEMPERATURE:
target_temp = ON_REPORT_SET_TEMPERATURE
await self.hass.async_add_executor_job(
self.data.set_target_temperature, target_temp, True
)
@@ -169,12 +173,12 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
translation_domain=DOMAIN,
translation_key="change_hvac_while_active_mode",
)
if self.hvac_mode == hvac_mode:
if self.hvac_mode is hvac_mode:
LOGGER.debug(
"%s is already in requested hvac mode %s", self.name, hvac_mode
)
return
if hvac_mode == HVACMode.OFF:
if hvac_mode is HVACMode.OFF:
await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE)
else:
if value_scheduled_preset(self.data) == PRESET_ECO:
@@ -208,16 +212,6 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
elif preset_mode == PRESET_ECO:
await self.async_set_temperature(temperature=self.data.eco_temperature)
@property
def min_temp(self) -> int:
"""Return the minimum temperature."""
return MIN_TEMPERATURE
@property
def max_temp(self) -> int:
"""Return the maximum temperature."""
return MAX_TEMPERATURE
@property
def extra_state_attributes(self) -> ClimateExtraAttributes:
"""Return the device specific state attributes."""

View File

@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyfritzhome"],
"requirements": ["pyfritzhome==0.6.14"],
"requirements": ["pyfritzhome==0.6.15"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:fritzbox:1"

View File

@@ -341,7 +341,7 @@ def get_next_departure(
{tomorrow_order}
origin_stop_time.departure_time
LIMIT :limit
"""
""" # noqa: S608
result = schedule.engine.connect().execute(
text(sql_query),
{

View File

@@ -119,12 +119,13 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
assert self.todo_items
if previous_uid:
pos = (
self.todo_items.index(
next(item for item in self.todo_items if item.uid == previous_uid)
)
+ 1
pos = self.todo_items.index(
next(item for item in self.todo_items if item.uid == previous_uid)
)
if pos < self.todo_items.index(
next(item for item in self.todo_items if item.uid == uid)
):
pos += 1
else:
pos = 0

View File

@@ -33,6 +33,7 @@ from homeassistant.components.backup import (
BackupReaderWriter,
BackupReaderWriterError,
CreateBackupEvent,
CreateBackupParametersDict,
CreateBackupStage,
CreateBackupState,
Folder,
@@ -635,7 +636,25 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
unsub()
async def async_validate_config(self, *, config: BackupConfig) -> None:
"""Validate backup config."""
"""Validate backup config.
Replace the core backup agent with the hassio default agent.
"""
core_agent_id = "backup.local"
create_backup = config.data.create_backup
if core_agent_id not in create_backup.agent_ids:
_LOGGER.debug("Backup settings don't need to be adjusted")
return
default_agent = await _default_agent(self._client)
_LOGGER.info("Adjusting backup settings to not include core backup location")
automatic_agents = [
agent_id if agent_id != core_agent_id else default_agent
for agent_id in create_backup.agent_ids
]
config.update(
create_backup=CreateBackupParametersDict(agent_ids=automatic_agents)
)
@callback
def _async_listen_job_events(

View File

@@ -5,24 +5,22 @@ import logging
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions
from pyheos import (
CommandAuthenticationError,
ConnectionState,
Heos,
HeosError,
HeosOptions,
)
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import selector
from homeassistant.helpers.service_info.ssdp import (
ATTR_UPNP_FRIENDLY_NAME,
SsdpServiceInfo,
)
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import DOMAIN
from .const import DOMAIN, ENTRY_TITLE
from .coordinator import HeosConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -37,11 +35,6 @@ AUTH_SCHEMA = vol.Schema(
)
def format_title(host: str) -> str:
"""Format the title for config entries."""
return f"HEOS System (via {host})"
async def _validate_host(host: str, errors: dict[str, str]) -> bool:
"""Validate host is reachable, return True, otherwise populate errors and return False."""
heos = Heos(HeosOptions(host, events=False, heart_beat=False))
@@ -56,13 +49,19 @@ async def _validate_host(host: str, errors: dict[str, str]) -> bool:
async def _validate_auth(
user_input: dict[str, str], heos: Heos, errors: dict[str, str]
user_input: dict[str, str], entry: HeosConfigEntry, errors: dict[str, str]
) -> bool:
"""Validate authentication by signing in or out, otherwise populate errors if needed."""
can_validate = (
hasattr(entry, "runtime_data")
and entry.runtime_data.heos.connection_state is ConnectionState.CONNECTED
)
if not user_input:
# Log out (neither username nor password provided)
if not can_validate:
return True
try:
await heos.sign_out()
await entry.runtime_data.heos.sign_out()
except HeosError:
errors["base"] = "unknown"
_LOGGER.exception("Unexpected error occurred during sign-out")
@@ -81,8 +80,12 @@ async def _validate_auth(
return False
# Attempt to login (both username and password provided)
if not can_validate:
return True
try:
await heos.sign_in(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
await entry.runtime_data.heos.sign_in(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
except CommandAuthenticationError as err:
errors["base"] = "invalid_auth"
_LOGGER.warning("Failed to sign-in to HEOS Account: %s", err)
@@ -94,16 +97,32 @@ async def _validate_auth(
else:
_LOGGER.debug(
"Successfully signed-in to HEOS Account: %s",
heos.signed_in_username,
entry.runtime_data.heos.signed_in_username,
)
return True
def _get_current_hosts(entry: HeosConfigEntry) -> set[str]:
"""Get a set of current hosts from the entry."""
hosts = set(entry.data[CONF_HOST])
if hasattr(entry, "runtime_data"):
hosts.update(
player.ip_address
for player in entry.runtime_data.heos.players.values()
if player.ip_address is not None
)
return hosts
class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
"""Define a flow for HEOS."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the HEOS flow."""
self._discovered_host: str | None = None
@staticmethod
@callback
def async_get_options_flow(config_entry: HeosConfigEntry) -> OptionsFlow:
@@ -117,40 +136,84 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
# Store discovered host
if TYPE_CHECKING:
assert discovery_info.ssdp_location
entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN)
hostname = urlparse(discovery_info.ssdp_location).hostname
friendly_name = f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]} ({hostname})"
self.hass.data.setdefault(DOMAIN, {})
self.hass.data[DOMAIN][friendly_name] = hostname
await self.async_set_unique_id(DOMAIN)
# Show selection form
return self.async_show_form(step_id="user")
assert hostname is not None
# Abort early when discovered host is part of the current system
if entry and hostname in _get_current_hosts(entry):
return self.async_abort(reason="single_instance_allowed")
# Connect to discovered host and get system information
heos = Heos(HeosOptions(hostname, events=False, heart_beat=False))
try:
await heos.connect()
system_info = await heos.get_system_info()
except HeosError as error:
_LOGGER.debug(
"Failed to retrieve system information from discovered HEOS device %s",
hostname,
exc_info=error,
)
return self.async_abort(reason="cannot_connect")
finally:
await heos.disconnect()
# Select the preferred host, if available
if system_info.preferred_hosts:
hostname = system_info.preferred_hosts[0].ip_address
# Move to confirmation when not configured
if entry is None:
self._discovered_host = hostname
return await self.async_step_confirm_discovery()
# Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload
if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]:
_LOGGER.debug(
"Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname
)
return self.async_update_reload_and_abort(
entry,
data_updates={CONF_HOST: hostname},
reason="reconfigure_successful",
)
return self.async_abort(reason="single_instance_allowed")
async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovered HEOS system."""
if user_input is not None:
assert self._discovered_host is not None
return self.async_create_entry(
title=ENTRY_TITLE, data={CONF_HOST: self._discovered_host}
)
self._set_confirm_only()
return self.async_show_form(step_id="confirm_discovery")
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Obtain host and validate connection."""
self.hass.data.setdefault(DOMAIN, {})
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured(error="single_instance_allowed")
# Try connecting to host if provided
errors: dict[str, str] = {}
host = None
if user_input is not None:
host = user_input[CONF_HOST]
# Map host from friendly name if in discovered hosts
host = self.hass.data[DOMAIN].get(host, host)
if await _validate_host(host, errors):
self.hass.data.pop(DOMAIN) # Remove discovery data
return self.async_create_entry(
title=format_title(host), data={CONF_HOST: host}
title=ENTRY_TITLE, data={CONF_HOST: host}
)
# Return form
host_type = (
str if not self.hass.data[DOMAIN] else vol.In(list(self.hass.data[DOMAIN]))
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): host_type}),
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}),
errors=errors,
)
@@ -186,8 +249,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
entry: HeosConfigEntry = self._get_reauth_entry()
if user_input is not None:
assert entry.state is ConfigEntryState.LOADED
if await _validate_auth(user_input, entry.runtime_data.heos, errors):
if await _validate_auth(user_input, entry, errors):
return self.async_update_reload_and_abort(entry, options=user_input)
return self.async_show_form(
@@ -208,8 +270,7 @@ class HeosOptionsFlowHandler(OptionsFlow):
"""Manage the options."""
errors: dict[str, str] = {}
if user_input is not None:
entry: HeosConfigEntry = self.config_entry
if await _validate_auth(user_input, entry.runtime_data.heos, errors):
if await _validate_auth(user_input, self.config_entry, errors):
return self.async_create_entry(data=user_input)
return self.async_show_form(

View File

@@ -3,6 +3,7 @@
ATTR_PASSWORD = "password"
ATTR_USERNAME = "username"
DOMAIN = "heos"
ENTRY_TITLE = "HEOS System"
SERVICE_GROUP_VOLUME_SET = "group_volume_set"
SERVICE_GROUP_VOLUME_DOWN = "group_volume_down"
SERVICE_GROUP_VOLUME_UP = "group_volume_up"

View File

@@ -9,7 +9,6 @@
"loggers": ["pyheos"],
"quality_scale": "silver",
"requirements": ["pyheos==1.0.2"],
"single_config_entry": true,
"ssdp": [
{
"st": "urn:schemas-denon-com:device:ACT-Denon:1"

View File

@@ -38,9 +38,7 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: todo
comment: Explore if this is possible.
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done

View File

@@ -11,6 +11,10 @@
"host": "Host name or IP address of a HEOS-capable product (preferably one connected via wire to the network)."
}
},
"confirm_discovery": {
"title": "Discovered HEOS System",
"description": "Do you want to add your HEOS devices to Home Assistant?"
},
"reconfigure": {
"title": "Reconfigure HEOS",
"description": "Change the host name or IP address of the HEOS-capable product used to access your HEOS System.",
@@ -43,6 +47,7 @@
},
"abort": {
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"

View File

@@ -124,9 +124,6 @@ class ExposedEntities:
websocket_api.async_register_command(self._hass, ws_expose_new_entities_get)
websocket_api.async_register_command(self._hass, ws_expose_new_entities_set)
websocket_api.async_register_command(self._hass, ws_list_exposed_entities)
websocket_api.async_register_command(
self._hass, ws_list_entities_exposed_to_assistant
)
await self._async_load_data()
@callback
@@ -455,30 +452,6 @@ def ws_list_exposed_entities(
connection.send_result(msg["id"], {"exposed_entities": result})
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "homeassistant/expose_entity/list_exposed",
vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS),
}
)
def ws_list_entities_exposed_to_assistant(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""List entities which are exposed to an assistant."""
exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
assistant = msg.get("assistant")
entity_registry = er.async_get(hass)
result = [
entity_id
for entity_id in chain(exposed_entities.entities, entity_registry.entities)
if assistant in (entity_settings := async_get_entity_settings(hass, entity_id))
and entity_settings[assistant].get("should_expose")
]
connection.send_result(msg["id"], {"exposed_entities": result})
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(

View File

@@ -28,12 +28,13 @@ from . import silabs_multiprotocol_addon
from .const import OTBR_DOMAIN, ZHA_DOMAIN
from .util import (
ApplicationType,
FirmwareInfo,
OwningAddon,
OwningIntegration,
get_otbr_addon_manager,
get_zigbee_flasher_addon_manager,
guess_hardware_owners,
probe_silabs_firmware_type,
probe_silabs_firmware_info,
)
_LOGGER = logging.getLogger(__name__)
@@ -52,7 +53,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Instantiate base flow."""
super().__init__(*args, **kwargs)
self._probed_firmware_type: ApplicationType | None = None
self._probed_firmware_info: FirmwareInfo | None = None
self._device: str | None = None # To be set in a subclass
self._hardware_name: str = "unknown" # To be set in a subclass
@@ -64,8 +65,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Shared translation placeholders."""
placeholders = {
"firmware_type": (
self._probed_firmware_type.value
if self._probed_firmware_type is not None
self._probed_firmware_info.firmware_type.value
if self._probed_firmware_info is not None
else "unknown"
),
"model": self._hardware_name,
@@ -120,39 +121,49 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
description_placeholders=self._get_translation_placeholders(),
)
async def _probe_firmware_type(self) -> bool:
"""Probe the firmware currently on the device."""
assert self._device is not None
self._probed_firmware_type = await probe_silabs_firmware_type(
self._device,
probe_methods=(
# We probe in order of frequency: Zigbee, Thread, then multi-PAN
ApplicationType.GECKO_BOOTLOADER,
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
),
)
return self._probed_firmware_type in (
async def _probe_firmware_info(
self,
probe_methods: tuple[ApplicationType, ...] = (
# We probe in order of frequency: Zigbee, Thread, then multi-PAN
ApplicationType.GECKO_BOOTLOADER,
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
),
) -> bool:
"""Probe the firmware currently on the device."""
assert self._device is not None
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
probe_methods=probe_methods,
)
return (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type
in (
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
)
)
async def async_step_pick_firmware_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Zigbee firmware."""
if not await self._probe_firmware_type():
if not await self._probe_firmware_info():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
# Allow the stick to be used with ZHA without flashing
if self._probed_firmware_type == ApplicationType.EZSP:
if (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type == ApplicationType.EZSP
):
return await self.async_step_confirm_zigbee()
if not is_hassio(self.hass):
@@ -338,7 +349,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Confirm Zigbee setup."""
assert self._device is not None
assert self._hardware_name is not None
self._probed_firmware_type = ApplicationType.EZSP
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
if user_input is not None:
await self.hass.config_entries.flow.async_init(
@@ -366,7 +382,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Thread firmware."""
if not await self._probe_firmware_type():
if not await self._probe_firmware_info():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
@@ -458,7 +474,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Confirm OTBR setup."""
assert self._device is not None
self._probed_firmware_type = ApplicationType.SPINEL
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
if user_input is not None:
# OTBR discovery is done automatically via hassio
@@ -497,14 +517,14 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow):
class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
"""Zigbee and Thread options flow handlers."""
_probed_firmware_info: FirmwareInfo
def __init__(self, config_entry: ConfigEntry, *args: Any, **kwargs: Any) -> None:
"""Instantiate options flow."""
super().__init__(*args, **kwargs)
self._config_entry = config_entry
self._probed_firmware_type = ApplicationType(self.config_entry.data["firmware"])
# Make `context` a regular dictionary
self.context = {}

View File

@@ -5,5 +5,5 @@
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": ["universal-silabs-flasher==0.0.25"]
"requirements": ["universal-silabs-flasher==0.0.29"]
}

View File

@@ -42,6 +42,7 @@ class ApplicationType(StrEnum):
CPC = "cpc"
EZSP = "ezsp"
SPINEL = "spinel"
ROUTER = "router"
@classmethod
def from_flasher_application_type(
@@ -248,10 +249,10 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
return guesses[-1][0]
async def probe_silabs_firmware_type(
async def probe_silabs_firmware_info(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
) -> ApplicationType | None:
"""Probe the running firmware on a Silabs device."""
) -> FirmwareInfo | None:
"""Probe the running firmware on a SiLabs device."""
flasher = Flasher(
device=device,
**(
@@ -269,4 +270,26 @@ async def probe_silabs_firmware_type(
if flasher.app_type is None:
return None
return ApplicationType.from_flasher_application_type(flasher.app_type)
return FirmwareInfo(
device=device,
firmware_type=ApplicationType.from_flasher_application_type(flasher.app_type),
firmware_version=(
flasher.app_version.orig_version
if flasher.app_version is not None
else None
),
source="probe",
owners=[],
)
async def probe_silabs_firmware_type(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
) -> ApplicationType | None:
"""Probe the running firmware type on a SiLabs device."""
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods)
if fw_info is None:
return None
return fw_info.firmware_type

View File

@@ -10,7 +10,10 @@ from homeassistant.components.homeassistant_hardware import (
firmware_config_flow,
silabs_multiprotocol_addon,
)
from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
)
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
@@ -118,7 +121,7 @@ class HomeAssistantSkyConnectConfigFlow(
"""Create the config entry."""
assert self._usb_info is not None
assert self._hw_variant is not None
assert self._probed_firmware_type is not None
assert self._probed_firmware_info is not None
return self.async_create_entry(
title=self._hw_variant.full_name,
@@ -130,7 +133,7 @@ class HomeAssistantSkyConnectConfigFlow(
"description": self._usb_info.description, # For backwards compatibility
"product": self._usb_info.description,
"device": self._usb_info.device,
"firmware": self._probed_firmware_type.value,
"firmware": self._probed_firmware_info.firmware_type.value,
},
)
@@ -203,18 +206,26 @@ class HomeAssistantSkyConnectOptionsFlowHandler(
self._hardware_name = self._hw_variant.full_name
self._device = self._usb_info.device
self._probed_firmware_info = FirmwareInfo(
device=self._device,
firmware_type=ApplicationType(self.config_entry.data["firmware"]),
firmware_version=None,
source="guess",
owners=[],
)
# Regenerate the translation placeholders
self._get_translation_placeholders()
def _async_flow_finished(self) -> ConfigFlowResult:
"""Create the config entry."""
assert self._probed_firmware_type is not None
assert self._probed_firmware_info is not None
self.hass.config_entries.async_update_entry(
entry=self.config_entry,
data={
**self.config_entry.data,
"firmware": self._probed_firmware_type.value,
"firmware": self._probed_firmware_info.firmware_type.value,
},
options=self.config_entry.options,
)

View File

@@ -24,7 +24,10 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
OptionsFlowHandler as MultiprotocolOptionsFlowHandler,
SerialPortSettings as MultiprotocolSerialPortSettings,
)
from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
)
from homeassistant.config_entries import (
SOURCE_HARDWARE,
ConfigEntry,
@@ -79,10 +82,13 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the initial step."""
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
await self._probe_firmware_type()
await self._probe_firmware_info()
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
if self._probed_firmware_type is ApplicationType.EZSP:
if (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type is ApplicationType.EZSP
):
discovery_flow.async_create_flow(
self.hass,
ZHA_DOMAIN,
@@ -98,7 +104,11 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
title=BOARD_NAME,
data={
# Assume the firmware type is EZSP if we cannot probe it
FIRMWARE: (self._probed_firmware_type or ApplicationType.EZSP).value,
FIRMWARE: (
self._probed_firmware_info.firmware_type
if self._probed_firmware_info is not None
else ApplicationType.EZSP
).value,
},
)
@@ -264,6 +274,14 @@ class HomeAssistantYellowOptionsFlowHandler(
self._hardware_name = BOARD_NAME
self._device = RADIO_DEVICE
self._probed_firmware_info = FirmwareInfo(
device=self._device,
firmware_type=ApplicationType(self.config_entry.data["firmware"]),
firmware_version=None,
source="guess",
owners=[],
)
# Regenerate the translation placeholders
self._get_translation_placeholders()
@@ -285,13 +303,13 @@ class HomeAssistantYellowOptionsFlowHandler(
def _async_flow_finished(self) -> ConfigFlowResult:
"""Create the config entry."""
assert self._probed_firmware_type is not None
assert self._probed_firmware_info is not None
self.hass.config_entries.async_update_entry(
entry=self.config_entry,
data={
**self.config_entry.data,
FIRMWARE: self._probed_firmware_type.value,
FIRMWARE: self._probed_firmware_info.firmware_type.value,
},
)

View File

@@ -14,7 +14,13 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.COVER, Platform.SENSOR, Platform.SWITCH]
PLATFORMS = [
Platform.BUTTON,
Platform.COVER,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
]
type HomeeConfigEntry = ConfigEntry[Homee]

View File

@@ -0,0 +1,78 @@
"""The homee button platform."""
from pyHomee.const import AttributeType
from pyHomee.model import HomeeAttribute
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
BUTTON_DESCRIPTIONS: dict[AttributeType, ButtonEntityDescription] = {
AttributeType.AUTOMATIC_MODE_IMPULSE: ButtonEntityDescription(key="automatic_mode"),
AttributeType.BRIEFLY_OPEN_IMPULSE: ButtonEntityDescription(key="briefly_open"),
AttributeType.IDENTIFICATION_MODE: ButtonEntityDescription(
key="identification_mode",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=ButtonDeviceClass.IDENTIFY,
),
AttributeType.IMPULSE: ButtonEntityDescription(key="impulse"),
AttributeType.LIGHT_IMPULSE: ButtonEntityDescription(key="light"),
AttributeType.OPEN_PARTIAL_IMPULSE: ButtonEntityDescription(key="open_partial"),
AttributeType.PERMANENTLY_OPEN_IMPULSE: ButtonEntityDescription(
key="permanently_open"
),
AttributeType.RESET_METER: ButtonEntityDescription(
key="reset_meter",
entity_category=EntityCategory.CONFIG,
),
AttributeType.VENTILATE_IMPULSE: ButtonEntityDescription(key="ventilate"),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the button component."""
async_add_entities(
HomeeButton(attribute, config_entry, BUTTON_DESCRIPTIONS[attribute.type])
for node in config_entry.runtime_data.nodes
for attribute in node.attributes
if attribute.type in BUTTON_DESCRIPTIONS and attribute.editable
)
class HomeeButton(HomeeEntity, ButtonEntity):
"""Representation of a Homee button."""
def __init__(
self,
attribute: HomeeAttribute,
entry: HomeeConfigEntry,
description: ButtonEntityDescription,
) -> None:
"""Initialize a Homee button entity."""
super().__init__(attribute, entry)
self.entity_description = description
if attribute.instance == 0:
if attribute.type == AttributeType.IMPULSE:
self._attr_name = None
else:
self._attr_translation_key = description.key
else:
self._attr_translation_key = f"{description.key}_instance"
self._attr_translation_placeholders = {"instance": str(attribute.instance)}
async def async_press(self) -> None:
"""Handle the button press."""
await self.async_set_value(1)

View File

@@ -76,6 +76,7 @@ CLIMATE_PROFILES = [
NodeProfile.WIFI_RADIATOR_THERMOSTAT,
NodeProfile.WIFI_ROOM_THERMOSTAT,
]
LIGHT_PROFILES = [
NodeProfile.DIMMABLE_COLOR_LIGHT,
NodeProfile.DIMMABLE_COLOR_METERING_PLUG,

View File

@@ -0,0 +1,213 @@
"""The Homee light platform."""
from typing import Any
from pyHomee.const import AttributeType
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import (
brightness_to_value,
color_hs_to_RGB,
color_RGB_to_hs,
value_to_brightness,
)
from . import HomeeConfigEntry
from .const import LIGHT_PROFILES
from .entity import HomeeNodeEntity
LIGHT_ATTRIBUTES = [
AttributeType.COLOR,
AttributeType.COLOR_MODE,
AttributeType.COLOR_TEMPERATURE,
AttributeType.DIMMING_LEVEL,
]
def is_light_node(node: HomeeNode) -> bool:
"""Determine if a node is controllable as a homee light based on its profile and attributes."""
assert node.attribute_map is not None
return node.profile in LIGHT_PROFILES and AttributeType.ON_OFF in node.attribute_map
def get_color_mode(supported_modes: set[ColorMode]) -> ColorMode:
"""Determine the color mode from the supported modes."""
if ColorMode.HS in supported_modes:
return ColorMode.HS
if ColorMode.COLOR_TEMP in supported_modes:
return ColorMode.COLOR_TEMP
if ColorMode.BRIGHTNESS in supported_modes:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
def get_light_attribute_sets(
node: HomeeNode,
) -> list[dict[AttributeType, HomeeAttribute]]:
"""Return the lights with their attributes as found in the node."""
lights: list[dict[AttributeType, HomeeAttribute]] = []
on_off_attributes = [
i for i in node.attributes if i.type == AttributeType.ON_OFF and i.editable
]
for a in on_off_attributes:
attribute_dict: dict[AttributeType, HomeeAttribute] = {a.type: a}
for attribute in node.attributes:
if attribute.instance == a.instance and attribute.type in LIGHT_ATTRIBUTES:
attribute_dict[attribute.type] = attribute
lights.append(attribute_dict)
return lights
def rgb_list_to_decimal(color: tuple[int, int, int]) -> int:
"""Convert an rgb color from list to decimal representation."""
return int(int(color[0]) << 16) + (int(color[1]) << 8) + (int(color[2]))
def decimal_to_rgb_list(color: float) -> list[int]:
"""Convert an rgb color from decimal to list representation."""
return [
(int(color) & 0xFF0000) >> 16,
(int(color) & 0x00FF00) >> 8,
(int(color) & 0x0000FF),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the light entity."""
async_add_entities(
HomeeLight(node, light, config_entry)
for node in config_entry.runtime_data.nodes
for light in get_light_attribute_sets(node)
if is_light_node(node)
)
class HomeeLight(HomeeNodeEntity, LightEntity):
"""Representation of a Homee light."""
def __init__(
self,
node: HomeeNode,
light: dict[AttributeType, HomeeAttribute],
entry: HomeeConfigEntry,
) -> None:
"""Initialize a Homee light."""
super().__init__(node, entry)
self._on_off_attr: HomeeAttribute = light[AttributeType.ON_OFF]
self._dimmer_attr: HomeeAttribute | None = light.get(
AttributeType.DIMMING_LEVEL
)
self._col_attr: HomeeAttribute | None = light.get(AttributeType.COLOR)
self._temp_attr: HomeeAttribute | None = light.get(
AttributeType.COLOR_TEMPERATURE
)
self._mode_attr: HomeeAttribute | None = light.get(AttributeType.COLOR_MODE)
self._attr_supported_color_modes = self._get_supported_color_modes()
self._attr_color_mode = get_color_mode(self._attr_supported_color_modes)
if self._temp_attr is not None:
self._attr_min_color_temp_kelvin = int(self._temp_attr.minimum)
self._attr_max_color_temp_kelvin = int(self._temp_attr.maximum)
if self._on_off_attr.instance > 0:
self._attr_translation_key = "light_instance"
self._attr_translation_placeholders = {
"instance": str(self._on_off_attr.instance)
}
else:
# If a device has only one light, it will get its name.
self._attr_name = None
self._attr_unique_id = (
f"{entry.runtime_data.settings.uid}-{self._node.id}-{self._on_off_attr.id}"
)
@property
def brightness(self) -> int:
"""Return the brightness of the light."""
assert self._dimmer_attr is not None
return value_to_brightness(
(self._dimmer_attr.minimum + 1, self._dimmer_attr.maximum),
self._dimmer_attr.current_value,
)
@property
def hs_color(self) -> tuple[float, float] | None:
"""Return the color of the light."""
assert self._col_attr is not None
rgb = decimal_to_rgb_list(self._col_attr.current_value)
return color_RGB_to_hs(*rgb)
@property
def color_temp_kelvin(self) -> int:
"""Return the color temperature of the light."""
assert self._temp_attr is not None
return int(self._temp_attr.current_value)
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return bool(self._on_off_attr.current_value)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
if ATTR_BRIGHTNESS in kwargs and self._dimmer_attr is not None:
target_value = round(
brightness_to_value(
(self._dimmer_attr.minimum, self._dimmer_attr.maximum),
kwargs[ATTR_BRIGHTNESS],
)
)
await self.async_set_value(self._dimmer_attr, target_value)
else:
# If no brightness value is given, just turn on.
await self.async_set_value(self._on_off_attr, 1)
if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temp_attr is not None:
await self.async_set_value(self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN])
if ATTR_HS_COLOR in kwargs:
color = kwargs[ATTR_HS_COLOR]
if self._col_attr is not None:
await self.async_set_value(
self._col_attr,
rgb_list_to_decimal(color_hs_to_RGB(*color)),
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
await self.async_set_value(self._on_off_attr, 0)
def _get_supported_color_modes(self) -> set[ColorMode]:
"""Determine the supported color modes from the available attributes."""
color_modes: set[ColorMode] = set()
if self._temp_attr is not None and self._temp_attr.editable:
color_modes.add(ColorMode.COLOR_TEMP)
if self._col_attr is not None:
color_modes.add(ColorMode.HS)
# If no other color modes are available, set one of those.
if len(color_modes) == 0:
if self._dimmer_attr is not None:
color_modes.add(ColorMode.BRIGHTNESS)
else:
color_modes.add(ColorMode.ONOFF)
return color_modes

View File

@@ -157,7 +157,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
AttributeType.RAIN_FALL_TODAY: HomeeSensorEntityDescription(
key="rainfall_day",
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
AttributeType.RELATIVE_HUMIDITY: HomeeSensorEntityDescription(
key="humidity",

View File

@@ -26,6 +26,46 @@
}
},
"entity": {
"button": {
"automatic_mode": {
"name": "Automatic mode"
},
"briefly_open": {
"name": "Briefly open"
},
"identification_mode": {
"name": "Identification mode"
},
"impulse_instance": {
"name": "Impulse {instance}"
},
"light": {
"name": "Light"
},
"light_instance": {
"name": "Light {instance}"
},
"open_partial": {
"name": "Open partially"
},
"permanently_open": {
"name": "Open permanently"
},
"reset_meter": {
"name": "Reset meter"
},
"reset_meter_instance": {
"name": "Reset meter {instance}"
},
"ventilate": {
"name": "Ventilate"
}
},
"light": {
"light_instance": {
"name": "Light {instance}"
}
},
"sensor": {
"brightness_instance": {
"name": "Illuminance {instance}"

View File

@@ -4,17 +4,20 @@ from __future__ import annotations
import logging
from inkbird_ble import INKBIRDBluetoothDeviceData
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfo,
)
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
from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN
from .const import CONF_DEVICE_TYPE, DOMAIN
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -25,20 +28,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up INKBIRD BLE device from a config entry."""
address = entry.unique_id
assert address is not None
data = INKBIRDBluetoothDeviceData()
coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (
PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=data.update,
)
device_type: str | None = entry.data.get(CONF_DEVICE_TYPE)
data = INKBIRDBluetoothDeviceData(device_type)
@callback
def _async_on_update(service_info: BluetoothServiceInfo) -> SensorUpdate:
"""Handle update callback from the passive BLE processor."""
nonlocal device_type
update = data.update(service_info)
if device_type is None and data.device_type is not None:
device_type_str = str(data.device_type)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_DEVICE_TYPE: device_type_str}
)
device_type = device_type_str
return update
coordinator = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=_async_on_update,
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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

View File

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

View File

@@ -28,5 +28,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/inkbird",
"iot_class": "local_push",
"requirements": ["inkbird-ble==0.5.8"]
"requirements": ["inkbird-ble==0.7.0"]
}

View File

@@ -20,7 +20,7 @@
"services": {
"select_next": {
"name": "Next",
"description": "Select the next option.",
"description": "Selects the next option.",
"fields": {
"cycle": {
"name": "Cycle",

View File

@@ -17,7 +17,7 @@ from homeassistant.util.dt import parse_datetime
from .browse_media import build_item_response, build_root_response
from .client_wrapper import get_artwork_url
from .const import CONTENT_TYPE_MAP, LOGGER
from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH
from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator
from .entity import JellyfinClientEntity
@@ -169,7 +169,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
if self.now_playing is None:
return None
return get_artwork_url(self.coordinator.api_client, self.now_playing, 150)
return get_artwork_url(
self.coordinator.api_client, self.now_playing, MAX_IMAGE_WIDTH
)
@property
def supported_features(self) -> MediaPlayerEntityFeature:

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -26,11 +26,14 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload config entry."""
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
"""Unload a config entry."""
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))

View File

@@ -0,0 +1 @@
"""LINAK virtual integration."""

View File

@@ -0,0 +1,6 @@
{
"domain": "linak",
"name": "LINAK",
"integration_type": "virtual",
"supported_by": "idasen_desk"
}

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -43,14 +43,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))

View File

@@ -277,6 +277,21 @@ FOUR_GROUP_REMOTE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend(
}
)
# See mappings at https://github.com/home-assistant/core/issues/137548#issuecomment-2643440119
PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP = {
"on": 2, # 'Number': 2 in LIP
"off": 4, # 'Number': 4 in LIP
}
PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP = {
"on": 0, # 'ButtonNumber': 0 in LEAP
"off": 2, # 'ButtonNumber': 2 in LEAP
}
PADDLE_SWITCH_PICO_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_SUBTYPE): vol.In(PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP),
}
)
DEVICE_TYPE_SCHEMA_MAP = {
"Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA,
@@ -288,6 +303,7 @@ DEVICE_TYPE_SCHEMA_MAP = {
"Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA,
"Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA,
"FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA,
"PaddleSwitchPico": PADDLE_SWITCH_PICO_TRIGGER_SCHEMA,
}
DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = {
@@ -300,6 +316,7 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = {
"Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LIP,
"Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LIP,
"FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP,
"PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP,
}
DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = {
@@ -312,6 +329,7 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = {
"Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP,
"Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP,
"FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP,
"PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP,
}
LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP: dict[str, dict[int, str]] = {
@@ -326,6 +344,7 @@ TRIGGER_SCHEMA = vol.Any(
PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA,
PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA,
FOUR_GROUP_REMOTE_TRIGGER_SCHEMA,
PADDLE_SWITCH_PICO_TRIGGER_SCHEMA,
)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -29,11 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))

View File

@@ -25,7 +25,6 @@ from mcp import types
from homeassistant.components import conversation
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import llm
@@ -56,11 +55,9 @@ def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry:
Will raise an HTTP error if the expected configuration is not present.
"""
config_entries: list[MCPServerConfigEntry] = [
config_entry
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.state == ConfigEntryState.LOADED
]
config_entries: list[MCPServerConfigEntry] = (
hass.config_entries.async_loaded_entries(DOMAIN)
)
if not config_entries:
raise HTTPNotFound(text="Model Context Protocol server is not configured")
if len(config_entries) > 1:

View File

@@ -299,22 +299,22 @@
"description": "Removes all items from the playlist."
},
"shuffle_set": {
"name": "Shuffle",
"description": "Playback mode that selects the media in randomized order.",
"name": "Set shuffle",
"description": "Enables or disables the shuffle mode.",
"fields": {
"shuffle": {
"name": "Shuffle",
"description": "Whether or not shuffle mode is enabled."
"name": "Shuffle mode",
"description": "Whether the media should be played in randomized order or not."
}
}
},
"repeat_set": {
"name": "Repeat",
"description": "Playback mode that plays the media in a loop.",
"name": "Set repeat",
"description": "Sets the repeat mode.",
"fields": {
"repeat": {
"name": "Repeat mode",
"description": "Repeat mode to set."
"description": "Whether the media (one or all) should be played in a loop or not."
}
}
},

View File

@@ -2,11 +2,11 @@
"services": {
"reload": {
"name": "[%key:common::action::reload%]",
"description": "Reloads all modbus entities."
"description": "Reloads all Modbus entities."
},
"write_coil": {
"name": "Write coil",
"description": "Writes to a modbus coil.",
"description": "Writes to a Modbus coil.",
"fields": {
"address": {
"name": "Address",
@@ -17,8 +17,8 @@
"description": "State to write."
},
"slave": {
"name": "Slave",
"description": "Address of the modbus unit/slave."
"name": "Server",
"description": "Address of the Modbus unit/server."
},
"hub": {
"name": "Hub",
@@ -28,7 +28,7 @@
},
"write_register": {
"name": "Write register",
"description": "Writes to a modbus holding register.",
"description": "Writes to a Modbus holding register.",
"fields": {
"address": {
"name": "[%key:component::modbus::services::write_coil::fields::address::name%]",

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -29,11 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))

View File

@@ -2,7 +2,7 @@
"services": {
"aux": {
"name": "Aux",
"description": "Trigger an aux output.",
"description": "Changes the state of an aux output.",
"fields": {
"output_id": {
"name": "Output ID",
@@ -10,17 +10,17 @@
},
"state": {
"name": "State",
"description": "The On/Off State. If P14xE 8E is enabled then a value of true will pulse output x for the time specified in P14(x+4)E."
"description": "The on/off state of the output. If P14xE 8E is enabled then turning on will pulse the output for the time specified in P14(x+4)E."
}
}
},
"panic": {
"name": "Panic",
"description": "Triggers a panic.",
"description": "Triggers a panic alarm.",
"fields": {
"code": {
"name": "Code",
"description": "The user code to use to trigger the panic."
"description": "The user code to use to trigger the panic alarm."
}
}
}

View File

@@ -7,7 +7,6 @@ from collections.abc import Mapping
from google_nest_sdm.device import Device
from google_nest_sdm.device_traits import ConnectivityTrait, InfoTrait
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -84,8 +83,7 @@ def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]:
"""Return a mapping of all nest devices for all config entries."""
return {
device.name: device
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.state == ConfigEntryState.LOADED
for config_entry in hass.config_entries.async_loaded_entries(DOMAIN)
for device in config_entry.runtime_data.device_manager.devices.values()
}

View File

@@ -39,7 +39,7 @@ set_preset_mode_with_end_datetime:
select:
options:
- "away"
- "Frost Guard"
- "frost_guard"
end_datetime:
required: true
example: '"2019-04-20 05:04:20"'

View File

@@ -99,6 +99,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
_async_notify_backup_listeners_soon(hass)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True

View File

@@ -31,7 +31,7 @@ from homeassistant.components.backup import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .const import CONF_DELETE_PERMANENTLY, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .coordinator import OneDriveConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -205,8 +205,12 @@ class OneDriveBackupAgent(BackupAgent):
backup = backups[backup_id]
await self._client.delete_drive_item(backup.backup_file_id)
await self._client.delete_drive_item(backup.metadata_file_id)
delete_permanently = self._entry.options.get(CONF_DELETE_PERMANENTLY, False)
await self._client.delete_drive_item(backup.backup_file_id, delete_permanently)
await self._client.delete_drive_item(
backup.metadata_file_id, delete_permanently
)
self._cache_expiration = time()
@handle_backup_errors

View File

@@ -1,18 +1,23 @@
"""Config flow for OneDrive."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any, cast
from onedrive_personal_sdk.clients.client import OneDriveClient
from onedrive_personal_sdk.exceptions import OneDriveException
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import DOMAIN, OAUTH_SCOPES
from .const import CONF_DELETE_PERMANENTLY, DOMAIN, OAUTH_SCOPES
from .coordinator import OneDriveConfigEntry
class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
@@ -86,3 +91,38 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
@staticmethod
@callback
def async_get_options_flow(
config_entry: OneDriveConfigEntry,
) -> OneDriveOptionsFlowHandler:
"""Create the options flow."""
return OneDriveOptionsFlowHandler()
class OneDriveOptionsFlowHandler(OptionsFlow):
"""Handles options flow for the component."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options for OneDrive."""
if user_input:
return self.async_create_entry(title="", data=user_input)
options_schema = vol.Schema(
{
vol.Optional(
CONF_DELETE_PERMANENTLY,
default=self.config_entry.options.get(
CONF_DELETE_PERMANENTLY, False
),
): bool,
}
)
return self.async_show_form(
step_id="init",
data_schema=options_schema,
)

View File

@@ -7,6 +7,8 @@ from homeassistant.util.hass_dict import HassKey
DOMAIN: Final = "onedrive"
CONF_DELETE_PERMANENTLY: Final = "delete_permanently"
# replace "consumers" with "common", when adding SharePoint or OneDrive for Business support
OAUTH2_AUTHORIZE: Final = (
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "bronze",
"requirements": ["onedrive-personal-sdk==0.0.10"]
"requirements": ["onedrive-personal-sdk==0.0.11"]
}

View File

@@ -30,10 +30,7 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
No Options flow.
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done

View File

@@ -29,6 +29,19 @@
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
"options": {
"step": {
"init": {
"description": "By default, files are put into the Recycle Bin when deleted, where they remain available for another 30 days. If you enable this option, files will be deleted immediately when they are cleaned up by the backup system.",
"data": {
"delete_permanently": "Delete files permanently"
},
"data_description": {
"delete_permanently": "Delete files without moving them to the Recycle Bin"
}
}
}
},
"issues": {
"drive_full": {
"title": "OneDrive data cap exceeded",

View File

@@ -235,7 +235,7 @@ class ONVIFDevice:
LOGGER.debug("%s: Retrieving current device date/time", self.name)
try:
device_time = await device_mgmt.GetSystemDateAndTime()
except RequestError as err:
except (RequestError, Fault) as err:
LOGGER.warning(
"Couldn't get device '%s' date/time. Error: %s", self.name, err
)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date
from opower import Forecast, MeterType, UnitOfMeasure
@@ -28,7 +29,7 @@ from .coordinator import OpowerConfigEntry, OpowerCoordinator
class OpowerEntityDescription(SensorEntityDescription):
"""Class describing Opower sensors entities."""
value_fn: Callable[[Forecast], str | float]
value_fn: Callable[[Forecast], str | float | date]
# suggested_display_precision=0 for all sensors since
@@ -96,7 +97,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: str(data.start_date),
value_fn=lambda data: data.start_date,
),
OpowerEntityDescription(
key="elec_end_date",
@@ -104,7 +105,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: str(data.end_date),
value_fn=lambda data: data.end_date,
),
)
GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
@@ -168,7 +169,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: str(data.start_date),
value_fn=lambda data: data.start_date,
),
OpowerEntityDescription(
key="gas_end_date",
@@ -176,7 +177,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: str(data.end_date),
value_fn=lambda data: data.end_date,
),
)
@@ -246,7 +247,7 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity):
self.utility_account_id = utility_account_id
@property
def native_value(self) -> StateType:
def native_value(self) -> StateType | date:
"""Return the state."""
if self.coordinator.data is not None:
return self.entity_description.value_fn(

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/prosegur",
"iot_class": "cloud_polling",
"loggers": ["pyprosegur"],
"requirements": ["pyprosegur==0.0.9"]
"requirements": ["pyprosegur==0.0.13"]
}

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from aiohttp import CookieJar
from pyloadapi.api import PyLoadAPI
from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
from homeassistant.const import (
CONF_HOST,
@@ -16,10 +15,8 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import DOMAIN
from .coordinator import PyLoadConfigEntry, PyLoadCoordinator
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
@@ -45,24 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo
password=entry.data[CONF_PASSWORD],
)
try:
await pyloadapi.login()
except CannotConnect as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_request_exception",
) from e
except ParserError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_parse_exception",
) from e
except InvalidAuth as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="setup_authentication_exception",
translation_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]},
) from e
coordinator = PyLoadCoordinator(hass, entry, pyloadapi)
await coordinator.async_config_entry_first_refresh()

View File

@@ -18,6 +18,8 @@ from .const import DOMAIN
from .coordinator import PyLoadConfigEntry
from .entity import BasePyLoadEntity
PARALLEL_UPDATES = 1
@dataclass(kw_only=True, frozen=True)
class PyLoadButtonEntityDescription(ButtonEntityDescription):

View File

@@ -9,7 +9,7 @@ from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -59,14 +59,11 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
async def _async_update_data(self) -> PyLoadData:
"""Fetch data from API endpoint."""
try:
if not self.version:
self.version = await self.pyload.version()
return PyLoadData(
**await self.pyload.get_status(),
free_space=await self.pyload.free_space(),
)
except InvalidAuth as e:
except InvalidAuth:
try:
await self.pyload.login()
except InvalidAuth as exc:
@@ -75,13 +72,38 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
translation_key="setup_authentication_exception",
translation_placeholders={CONF_USERNAME: self.pyload.username},
) from exc
raise UpdateFailed(
"Unable to retrieve data due to cookie expiration"
) from e
_LOGGER.debug(
"Unable to retrieve data due to cookie expiration, retrying after 20 seconds"
)
return self.data
except CannotConnect as e:
raise UpdateFailed(
"Unable to connect and retrieve data from pyLoad API"
) from e
except ParserError as e:
raise UpdateFailed("Unable to parse data from pyLoad API") from e
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.pyload.login()
self.version = await self.pyload.version()
except CannotConnect as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_request_exception",
) from e
except ParserError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_parse_exception",
) from e
except InvalidAuth as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="setup_authentication_exception",
translation_placeholders={
CONF_USERNAME: self.config_entry.data[CONF_USERNAME]
},
) from e

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["pyloadapi"],
"requirements": ["PyLoadAPI==1.3.2"]
"requirements": ["PyLoadAPI==1.4.1"]
}

View File

@@ -21,6 +21,8 @@ from .const import UNIT_DOWNLOADS
from .coordinator import PyLoadConfigEntry, PyLoadData
from .entity import BasePyLoadEntity
PARALLEL_UPDATES = 0
class PyLoadSensorEntity(StrEnum):
"""pyLoad Sensor Entities."""

View File

@@ -22,6 +22,8 @@ from .const import DOMAIN
from .coordinator import PyLoadConfigEntry, PyLoadData
from .entity import BasePyLoadEntity
PARALLEL_UPDATES = 1
class PyLoadSwitch(StrEnum):
"""PyLoad Switch Entities."""

View File

@@ -71,17 +71,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> boo
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
entry.runtime_data.shutdown()
cleanup(hass, entry)
_cleanup(hass, entry)
return unload_ok
def cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None:
def _cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None:
"""Shutdown if no more entries are loaded."""
entries = hass.config_entries.async_loaded_entries(DOMAIN)
count = len(entries)
# During unloading of the entry, it is not marked as unloaded yet. So
# count can be 1 if it is the last one.
if count <= 1 and (config_coordinator := hass.data.get(QBUS_KEY)):
if not hass.config_entries.async_loaded_entries(DOMAIN) and (
config_coordinator := hass.data.get(QBUS_KEY)
):
config_coordinator.shutdown()

View File

@@ -5,7 +5,10 @@ from typing import Final
from homeassistant.const import Platform
DOMAIN: Final = "qbus"
PLATFORMS: list[Platform] = [Platform.SWITCH]
PLATFORMS: list[Platform] = [
Platform.LIGHT,
Platform.SWITCH,
]
CONF_SERIAL_NUMBER: Final = "serial"

View File

@@ -1,6 +1,9 @@
"""Base class for Qbus entities."""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
import re
from qbusmqttapi.discovery import QbusMqttOutput
@@ -10,12 +13,36 @@ from qbusmqttapi.state import QbusMqttState
from homeassistant.components.mqtt import ReceiveMessage, client as mqtt
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
from .coordinator import QbusControllerCoordinator
_REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$")
def add_new_outputs(
coordinator: QbusControllerCoordinator,
added_outputs: list[QbusMqttOutput],
filter_fn: Callable[[QbusMqttOutput], bool],
entity_type: type[QbusEntity],
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Call async_add_entities for new outputs."""
added_ref_ids = {k.ref_id for k in added_outputs}
new_outputs = [
output
for output in coordinator.data
if filter_fn(output) and output.ref_id not in added_ref_ids
]
if new_outputs:
added_outputs.extend(new_outputs)
async_add_entities([entity_type(output) for output in new_outputs])
def format_ref_id(ref_id: str) -> str | None:
"""Format the Qbus ref_id."""
matches: list[str] = re.findall(_REFID_REGEX, ref_id)

View File

@@ -0,0 +1,110 @@
"""Support for Qbus light."""
from typing import Any
from qbusmqttapi.discovery import QbusMqttOutput
from qbusmqttapi.state import QbusMqttAnalogState, StateType
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import brightness_to_value, value_to_brightness
from .coordinator import QbusConfigEntry
from .entity import QbusEntity, add_new_outputs
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: QbusConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up light entities."""
coordinator = entry.runtime_data
added_outputs: list[QbusMqttOutput] = []
def _check_outputs() -> None:
add_new_outputs(
coordinator,
added_outputs,
lambda output: output.type == "analog",
QbusLight,
async_add_entities,
)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
class QbusLight(QbusEntity, LightEntity):
"""Representation of a Qbus light entity."""
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_color_mode = ColorMode.BRIGHTNESS
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
"""Initialize light entity."""
super().__init__(mqtt_output)
self._set_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
percentage: int | None = None
on: bool | None = None
state = QbusMqttAnalogState(id=self._mqtt_output.id)
if brightness is None:
on = True
state.type = StateType.ACTION
state.write_on_off(on)
else:
percentage = round(brightness_to_value((1, 100), brightness))
state.type = StateType.STATE
state.write_percentage(percentage)
await self._async_publish_output_state(state)
self._set_state(percentage=percentage, on=on)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
state = QbusMqttAnalogState(id=self._mqtt_output.id, type=StateType.ACTION)
state.write_on_off(on=False)
await self._async_publish_output_state(state)
self._set_state(on=False)
async def _state_received(self, msg: ReceiveMessage) -> None:
output = self._message_factory.parse_output_state(
QbusMqttAnalogState, msg.payload
)
if output is not None:
percentage = round(output.read_percentage())
self._set_state(percentage=percentage)
self.async_schedule_update_ha_state()
def _set_state(
self, *, percentage: int | None = None, on: bool | None = None
) -> None:
if percentage is None:
# When turning on without brightness, we don't know the desired
# brightness. It will be set during _state_received().
if on is True:
self._attr_is_on = True
else:
self._attr_is_on = False
self._attr_brightness = 0
else:
self._attr_is_on = percentage > 0
self._attr_brightness = value_to_brightness((1, 100), percentage)

View File

@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import QbusConfigEntry
from .entity import QbusEntity
from .entity import QbusEntity, add_new_outputs
PARALLEL_UPDATES = 0
@@ -19,26 +19,21 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: QbusConfigEntry,
add_entities: AddConfigEntryEntitiesCallback,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch entities."""
coordinator = entry.runtime_data
coordinator = entry.runtime_data
added_outputs: list[QbusMqttOutput] = []
# Local function that calls add_entities for new entities
def _check_outputs() -> None:
added_output_ids = {k.id for k in added_outputs}
new_outputs = [
item
for item in coordinator.data
if item.type == "onoff" and item.id not in added_output_ids
]
if new_outputs:
added_outputs.extend(new_outputs)
add_entities([QbusSwitch(output) for output in new_outputs])
add_new_outputs(
coordinator,
added_outputs,
lambda output: output.type == "onoff",
QbusSwitch,
async_add_entities,
)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
@@ -49,10 +44,7 @@ class QbusSwitch(QbusEntity, SwitchEntity):
_attr_device_class = SwitchDeviceClass.SWITCH
def __init__(
self,
mqtt_output: QbusMqttOutput,
) -> None:
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
"""Initialize switch entity."""
super().__init__(mqtt_output)

View File

@@ -43,6 +43,7 @@ from homeassistant.helpers.event import (
async_track_time_interval,
async_track_utc_time_change,
)
from homeassistant.helpers.recorder import DATA_RECORDER
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
@@ -183,7 +184,7 @@ class Recorder(threading.Thread):
self.db_retry_wait = db_retry_wait
self.database_engine: DatabaseEngine | None = None
# Database connection is ready, but non-live migration may be in progress
db_connected: asyncio.Future[bool] = hass.data[DOMAIN].db_connected
db_connected: asyncio.Future[bool] = hass.data[DATA_RECORDER].db_connected
self.async_db_connected: asyncio.Future[bool] = db_connected
# Database is ready to use but live migration may be in progress
self.async_db_ready: asyncio.Future[bool] = hass.loop.create_future()

View File

@@ -24,6 +24,7 @@ import voluptuous as vol
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant, callback, valid_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.recorder import DATA_RECORDER
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
@@ -561,7 +562,9 @@ def _compile_statistics(
platform_stats: list[StatisticResult] = []
current_metadata: dict[str, tuple[int, StatisticMetaData]] = {}
# Collect statistics from all platforms implementing support
for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items():
for domain, platform in instance.hass.data[
DATA_RECORDER
].recorder_platforms.items():
if not (
platform_compile_statistics := getattr(
platform, INTEGRATION_PLATFORM_COMPILE_STATISTICS, None
@@ -599,7 +602,7 @@ def _compile_statistics(
if start.minute == 50:
# Once every hour, update issues
for platform in instance.hass.data[DOMAIN].recorder_platforms.values():
for platform in instance.hass.data[DATA_RECORDER].recorder_platforms.values():
if not (
platform_update_issues := getattr(
platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None
@@ -882,7 +885,7 @@ def list_statistic_ids(
# the integrations for the missing ones.
#
# Query all integrations with a registered recorder platform
for platform in hass.data[DOMAIN].recorder_platforms.values():
for platform in hass.data[DATA_RECORDER].recorder_platforms.values():
if not (
platform_list_statistic_ids := getattr(
platform, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, None
@@ -2232,7 +2235,7 @@ def _sorted_statistics_to_dict(
def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]]:
"""Validate statistics."""
platform_validation: dict[str, list[ValidationIssue]] = {}
for platform in hass.data[DOMAIN].recorder_platforms.values():
for platform in hass.data[DATA_RECORDER].recorder_platforms.values():
if platform_validate_statistics := getattr(
platform, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, None
):
@@ -2243,7 +2246,7 @@ def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]]
def update_statistics_issues(hass: HomeAssistant) -> None:
"""Update statistics issues."""
with session_scope(hass=hass, read_only=True) as session:
for platform in hass.data[DOMAIN].recorder_platforms.values():
for platform in hass.data[DATA_RECORDER].recorder_platforms.values():
if platform_update_statistics_issues := getattr(
platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None
):

View File

@@ -11,11 +11,11 @@ import logging
import threading
from typing import TYPE_CHECKING, Any
from homeassistant.helpers.recorder import DATA_RECORDER
from homeassistant.helpers.typing import UndefinedType
from homeassistant.util.event_type import EventType
from . import entity_registry, purge, statistics
from .const import DOMAIN
from .db_schema import Statistics, StatisticsShortTerm
from .models import StatisticData, StatisticMetaData
from .util import periodic_db_cleanups, session_scope
@@ -308,7 +308,7 @@ class AddRecorderPlatformTask(RecorderTask):
hass = instance.hass
domain = self.domain
platform = self.platform
platforms: dict[str, Any] = hass.data[DOMAIN].recorder_platforms
platforms: dict[str, Any] = hass.data[DATA_RECORDER].recorder_platforms
platforms[domain] = platform

View File

@@ -1,33 +1,28 @@
"""Support to interact with Remember The Milk."""
"""The Remember The Milk integration."""
import json
import logging
from pathlib import Path
from __future__ import annotations
from rtmapi import Rtm
from aiortm import AioRTMClient, Auth, AuthError
import voluptuous as vol
from homeassistant.components import configurator
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_API_KEY,
CONF_ID,
CONF_NAME,
CONF_TOKEN,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER
from .entity import RememberTheMilkEntity
# httplib2 is a transitive dependency from RtmAPI. If this dependency is not
# set explicitly, the library does not work.
_LOGGER = logging.getLogger(__name__)
DOMAIN = "remember_the_milk"
DEFAULT_NAME = DOMAIN
CONF_SHARED_SECRET = "shared_secret"
CONF_ID_MAP = "id_map"
CONF_LIST_ID = "list_id"
CONF_TIMESERIES_ID = "timeseries_id"
CONF_TASK_ID = "task_id"
from .storage import RememberTheMilkConfiguration
RTM_SCHEMA = vol.Schema(
{
@@ -41,7 +36,6 @@ CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA])}, extra=vol.ALLOW_EXTRA
)
CONFIG_FILE_NAME = ".remember_the_milk.conf"
SERVICE_CREATE_TASK = "create_task"
SERVICE_COMPLETE_TASK = "complete_task"
@@ -51,205 +45,106 @@ SERVICE_SCHEMA_CREATE_TASK = vol.Schema(
SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string})
DATA_COMPONENT = "component"
DATA_ENTITY_ID = "entity_id"
DATA_STORAGE = "storage"
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Remember the milk component."""
component = EntityComponent[RememberTheMilkEntity](_LOGGER, DOMAIN, hass)
stored_rtm_config = RememberTheMilkConfiguration(hass)
hass.data[DOMAIN] = {}
hass.data[DOMAIN][DATA_COMPONENT] = EntityComponent[RememberTheMilkEntity](
LOGGER, DOMAIN, hass
)
storage = hass.data[DOMAIN][DATA_STORAGE] = RememberTheMilkConfiguration(hass)
await hass.async_add_executor_job(storage.setup)
if DOMAIN not in config:
return True
for rtm_config in config[DOMAIN]:
account_name = rtm_config[CONF_NAME]
_LOGGER.debug("Adding Remember the milk account %s", account_name)
api_key = rtm_config[CONF_API_KEY]
shared_secret = rtm_config[CONF_SHARED_SECRET]
token = stored_rtm_config.get_token(account_name)
if token:
_LOGGER.debug("found token for account %s", account_name)
_create_instance(
hass,
account_name,
api_key,
shared_secret,
token,
stored_rtm_config,
component,
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=rtm_config,
)
else:
_register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component
)
_LOGGER.debug("Finished adding all Remember the milk accounts")
)
return True
def _create_instance(
hass, account_name, api_key, shared_secret, token, stored_rtm_config, component
):
entity = RememberTheMilkEntity(
account_name, api_key, shared_secret, token, stored_rtm_config
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Remember The Milk from a config entry."""
component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][
DATA_COMPONENT
]
storage: RememberTheMilkConfiguration = hass.data[DOMAIN][DATA_STORAGE]
rtm_config = entry.data
account_name: str = rtm_config[CONF_USERNAME]
LOGGER.debug("Adding Remember the milk account %s", account_name)
api_key: str = rtm_config[CONF_API_KEY]
shared_secret: str = rtm_config[CONF_SHARED_SECRET]
token: str | None = rtm_config[CONF_TOKEN] # None if imported from YAML
client = AioRTMClient(
Auth(
client_session=async_get_clientsession(hass),
api_key=api_key,
shared_secret=shared_secret,
auth_token=token,
permission="delete",
)
)
component.add_entities([entity])
hass.services.register(
token_valid = True
try:
await client.rtm.api.check_token()
except AuthError as err:
token_valid = False
if entry.source == SOURCE_IMPORT:
raise ConfigEntryAuthFailed("Missing token") from err
if (known_entity_ids := hass.data[DOMAIN].get(DATA_ENTITY_ID)) and (
entity_id := known_entity_ids.get(account_name)
):
await component.async_remove_entity(entity_id)
# The entity will be deprecated when a todo platform is added.
entity = RememberTheMilkEntity(
name=account_name,
client=client,
config_entry_id=entry.entry_id,
storage=storage,
token_valid=token_valid,
)
await component.async_add_entities([entity])
known_entity_ids = hass.data[DOMAIN].setdefault(DATA_ENTITY_ID, {})
known_entity_ids[account_name] = entity.entity_id
# The services are registered here for now because they need the account name.
# The services will be deprecated when a todo platform is added.
hass.services.async_register(
DOMAIN,
f"{account_name}_create_task",
entity.create_task,
schema=SERVICE_SCHEMA_CREATE_TASK,
)
hass.services.register(
hass.services.async_register(
DOMAIN,
f"{account_name}_complete_task",
entity.complete_task,
schema=SERVICE_SCHEMA_COMPLETE_TASK,
)
if not token_valid:
raise ConfigEntryAuthFailed("Invalid token")
def _register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component
):
request_id = None
api = Rtm(api_key, shared_secret, "write", None)
url, frob = api.authenticate_desktop()
_LOGGER.debug("Sent authentication request to server")
def register_account_callback(fields: list[dict[str, str]]) -> None:
"""Call for register the configurator."""
api.retrieve_token(frob)
token = api.token
if api.token is None:
_LOGGER.error("Failed to register, please try again")
configurator.notify_errors(
hass, request_id, "Failed to register, please try again."
)
return
stored_rtm_config.set_token(account_name, token)
_LOGGER.debug("Retrieved new token from server")
_create_instance(
hass,
account_name,
api_key,
shared_secret,
token,
stored_rtm_config,
component,
)
configurator.request_done(hass, request_id)
request_id = configurator.request_config(
hass,
f"{DOMAIN} - {account_name}",
callback=register_account_callback,
description=(
"You need to log in to Remember The Milk to"
"connect your account. \n\n"
"Step 1: Click on the link 'Remember The Milk login'\n\n"
"Step 2: Click on 'login completed'"
),
link_name="Remember The Milk login",
link_url=url,
submit_caption="login completed",
)
return True
class RememberTheMilkConfiguration:
"""Internal configuration data for RememberTheMilk class.
This class stores the authentication token it get from the backend.
"""
def __init__(self, hass: HomeAssistant) -> None:
"""Create new instance of configuration."""
self._config_file_path = hass.config.path(CONFIG_FILE_NAME)
self._config = {}
_LOGGER.debug("Loading configuration from file: %s", self._config_file_path)
try:
self._config = json.loads(
Path(self._config_file_path).read_text(encoding="utf8")
)
except FileNotFoundError:
_LOGGER.debug("Missing configuration file: %s", self._config_file_path)
except OSError:
_LOGGER.debug(
"Failed to read from configuration file, %s, using empty configuration",
self._config_file_path,
)
except ValueError:
_LOGGER.error(
"Failed to parse configuration file, %s, using empty configuration",
self._config_file_path,
)
def _save_config(self) -> None:
"""Write the configuration to a file."""
Path(self._config_file_path).write_text(
json.dumps(self._config), encoding="utf8"
)
def get_token(self, profile_name: str) -> str | None:
"""Get the server token for a profile."""
if profile_name in self._config:
return self._config[profile_name][CONF_TOKEN]
return None
def set_token(self, profile_name: str, token: str) -> None:
"""Store a new server token for a profile."""
self._initialize_profile(profile_name)
self._config[profile_name][CONF_TOKEN] = token
self._save_config()
def delete_token(self, profile_name: str) -> None:
"""Delete a token for a profile.
Usually called when the token has expired.
"""
self._config.pop(profile_name, None)
self._save_config()
def _initialize_profile(self, profile_name: str) -> None:
"""Initialize the data structures for a profile."""
if profile_name not in self._config:
self._config[profile_name] = {}
if CONF_ID_MAP not in self._config[profile_name]:
self._config[profile_name][CONF_ID_MAP] = {}
def get_rtm_id(
self, profile_name: str, hass_id: str
) -> tuple[str, str, str] | None:
"""Get the RTM ids for a Home Assistant task ID.
The id of a RTM tasks consists of the tuple:
list id, timeseries id and the task id.
"""
self._initialize_profile(profile_name)
ids = self._config[profile_name][CONF_ID_MAP].get(hass_id)
if ids is None:
return None
return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID]
def set_rtm_id(
self,
profile_name: str,
hass_id: str,
list_id: str,
time_series_id: str,
rtm_task_id: str,
) -> None:
"""Add/Update the RTM task ID for a Home Assistant task IS."""
self._initialize_profile(profile_name)
id_tuple = {
CONF_LIST_ID: list_id,
CONF_TIMESERIES_ID: time_series_id,
CONF_TASK_ID: rtm_task_id,
}
self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple
self._save_config()
def delete_rtm_id(self, profile_name: str, hass_id: str) -> None:
"""Delete a key mapping."""
self._initialize_profile(profile_name)
if hass_id in self._config[profile_name][CONF_ID_MAP]:
del self._config[profile_name][CONF_ID_MAP][hass_id]
self._save_config()
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][
DATA_COMPONENT
]
entity_id = hass.data[DOMAIN][DATA_ENTITY_ID].pop(entry.data[CONF_USERNAME])
await component.async_remove_entity(entity_id)
return True

View File

@@ -0,0 +1,157 @@
"""Config flow for Remember The Milk integration."""
from __future__ import annotations
import asyncio
from collections.abc import Mapping
from typing import Any
from aiortm import Auth, AuthError, ResponseError
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_IMPORT,
SOURCE_REAUTH,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_TOKEN, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER
TOKEN_TIMEOUT_SEC = 30
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_SHARED_SECRET): str,
}
)
class RTMConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Remember The Milk."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._auth: Auth | None = None
self._url: str | None = None
self._frob: str | None = None
self._auth_data: dict[str, str] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._auth_data = user_input
auth = self._auth = Auth(
client_session=async_get_clientsession(self.hass),
api_key=user_input[CONF_API_KEY],
shared_secret=user_input[CONF_SHARED_SECRET],
permission="delete",
)
try:
self._url, self._frob = await auth.authenticate_desktop()
except AuthError:
errors["base"] = "invalid_auth"
except ResponseError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001 pylint: disable=broad-except
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return await self.async_step_auth()
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Authorize the application."""
assert self._url is not None
if user_input is not None:
return await self._get_token()
return self.async_show_form(
step_id="auth", description_placeholders={"url": self._url}
)
async def _get_token(self) -> ConfigFlowResult:
"""Get token and create config entry."""
assert self._auth is not None
assert self._frob is not None
assert self._auth_data is not None
try:
async with asyncio.timeout(TOKEN_TIMEOUT_SEC):
token = await self._auth.get_token(self._frob)
except TimeoutError:
return self.async_abort(reason="timeout_token")
except AuthError:
return self.async_abort(reason="invalid_auth")
except ResponseError:
return self.async_abort(reason="cannot_connect")
except Exception: # noqa: BLE001 pylint: disable=broad-except
LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
await self.async_set_unique_id(token["user"]["id"])
data = {
**self._auth_data,
CONF_TOKEN: token["token"],
CONF_USERNAME: token["user"]["username"],
}
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
if reauth_entry.source == SOURCE_IMPORT and reauth_entry.unique_id is None:
# Imported entries do not have a token nor unique id.
# Update unique id to match the new token.
# This case can be removed when the import step is removed.
self.hass.config_entries.async_update_entry(
reauth_entry, data=data, unique_id=token["user"]["id"]
)
else:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates=data,
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=token["user"]["fullname"],
data=data,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
return await self.async_step_user()
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import a config entry.
The token will be retrieved after config entry setup in a reauth flow.
"""
name = import_info.pop(CONF_NAME)
return self.async_create_entry(
title=name,
data=import_info | {CONF_USERNAME: name, CONF_TOKEN: None},
)

View File

@@ -0,0 +1,7 @@
"""Constants for the Remember The Milk integration."""
import logging
CONF_SHARED_SECRET = "shared_secret"
DOMAIN = "remember_the_milk"
LOGGER = logging.getLogger(__package__)

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