Compare commits

..

139 Commits

Author SHA1 Message Date
Franck Nijhof
0e5fc44af3 2026.4.0 (#166513) 2026-04-01 14:32:20 +02:00
Franck Nijhof
803531125b Bump version to 2026.4.0 2026-04-01 12:05:57 +00:00
Simone Chemelli
c70ddd559b Bump aioamazondevices to 13.3.2 (#167052) 2026-04-01 11:56:57 +00:00
Franck Nijhof
c06d898b00 Bump version to 2026.4.0b10 2026-04-01 10:23:39 +00:00
Bram Kragten
c6233d02e8 Update frontend to 20260325.5 (#167050) 2026-04-01 10:23:27 +00:00
Stefan Agner
37e69cad16 Store received backup in temp backup dir only (#166982) 2026-04-01 09:12:28 +00:00
Franck Nijhof
b14e729b2d Bump version to 2026.4.0b9 2026-04-01 06:35:41 +00:00
TheJulianJES
87e0f2d36c Bump ZHA to 1.1.1 (#167025) 2026-04-01 06:35:30 +00:00
J. Nick Koston
ae60135a08 Bump aiohttp to 3.13.5 (#167015) 2026-04-01 06:35:29 +00:00
Marc Mueller
3ed2dccbec Update requests to 2.33.1 (#167014) 2026-04-01 06:35:28 +00:00
Jackson_57
689ee7c1e7 Bump led-ble to 1.1.8 (#166999) 2026-04-01 06:35:26 +00:00
Joost Lekkerkerker
12d6d7ef88 Add BEGA brand (#166992) 2026-04-01 06:35:25 +00:00
dontinelli
4f88c5ed29 Bump solarlog_cli to 0.7.1 (#166990) 2026-04-01 06:35:24 +00:00
Joost Lekkerkerker
35826dfd14 Pull out Dropbox integration (#166986) 2026-04-01 06:35:22 +00:00
Ariel Ebersberger
12dc33eabc Add skeleton with repair issue to bmw integration (#166983)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-04-01 06:35:21 +00:00
Joost Lekkerkerker
9650aea6a1 Make sure we can fetch player stats in Chess.com (#166980) 2026-04-01 06:35:19 +00:00
Norbert Rittel
aaff319e70 Fix grammar of input_shutdown_failure error in victron_ble (#166972) 2026-04-01 06:35:18 +00:00
Bram Kragten
d9babc37f0 Bump version to 2026.4.0b8 2026-03-31 20:00:43 +02:00
Bram Kragten
a616de7452 Update frontend to 20260325.4 (#166970) 2026-03-31 20:00:23 +02:00
Erik Montnemery
817d3e1178 Remove redundant field descriptions from triggers and conditions (#166955) 2026-03-31 20:00:21 +02:00
Abílio Costa
e353ed1e2e Add counter purpose-specific condition (#166879) 2026-03-31 20:00:21 +02:00
Erik Montnemery
96b7210bca Add calendar conditions (#166643) 2026-03-31 20:00:19 +02:00
Erik Montnemery
22a6968a08 Add timer conditions (#166641)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-31 20:00:19 +02:00
Erik Montnemery
ce8519c1b1 Update hassfest conditions, services and triggers plugins to not require field descriptions (#166954)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-31 19:39:03 +02:00
Erik Montnemery
871d9ee0b4 Remove calendar and todo from unconditionally loaded integrations (#166951)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2026-03-31 19:39:02 +02:00
Paul Bottein
11d9f236b9 Fix "Shutdown" grammar in Roborock strings (#166948)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:39:01 +02:00
Artur Pragacz
8be6f441dd Register condition platform upon use (#166939) 2026-03-31 19:32:20 +02:00
Manu
d432092296 Fix StopIteration error in ista EcoTrend coordinator (#166929) 2026-03-31 19:32:19 +02:00
Branden Cash
4d168023a2 Bump srpenergy to 1.3.8 (#166926) 2026-03-31 19:32:17 +02:00
Artur Pragacz
d4d639dfa2 Register trigger platform upon use (#166911) 2026-03-31 19:32:15 +02:00
Erik Montnemery
92375078c0 Make field description optional for non config flows (#166892) 2026-03-31 19:32:14 +02:00
Andreas Jakl
fc6efac559 Prevent invalid phase count state in nrgkick (#166575) 2026-03-31 19:32:13 +02:00
Franck Nijhof
a9e1bbd5ab Improve time action naming consistency (#166532) 2026-03-31 19:32:11 +02:00
Franck Nijhof
dcf6416ae9 Improve datetime action naming consistency (#166530) 2026-03-31 19:32:10 +02:00
Franck Nijhof
df6b2ba0cd Improve date action naming consistency (#166529) 2026-03-31 19:32:10 +02:00
Franck Nijhof
19166e7938 Bump version to 2026.4.0b7 2026-03-31 08:25:00 +00:00
Robert Resch
3472a2bfbf Use async download for translations (#166940) 2026-03-31 08:24:51 +00:00
Franck Nijhof
8ac66e888e Bump version to 2026.4.0b6 2026-03-31 07:37:18 +00:00
Manu
39f2e89c4b Bump aiontfy to 0.8.4 (#166917) 2026-03-31 07:36:13 +00:00
Brett Adams
fa0ea041ad Fix Tesla Fleet startup scopes after OAuth refresh (#166922) 2026-03-31 07:34:18 +00:00
Manu
46b1981b77 Bump aiontfy to 0.8.3 (#166770) 2026-03-31 07:34:17 +00:00
Michael
29980d69b5 Add valve.opened and valve.closed triggers (#165160) 2026-03-31 07:29:21 +00:00
Raj Laud
3a81eb9552 Bump victron-ble-ha-parser (#166906) 2026-03-31 07:26:46 +00:00
Artur Pragacz
06e8333eab Unprefix entity name for entity ID generation (#166900) 2026-03-31 07:26:44 +00:00
Artur Pragacz
8ee0b97e5f Unprefix entity name for template function (#166899) 2026-03-31 07:26:43 +00:00
Joost Lekkerkerker
414756edc4 Get list of analytics insights integrations from next environment (#166867) 2026-03-31 07:26:42 +00:00
Michal Čihař
1355958f53 Skip unavailable sensors in LaCrosse View (#166859) 2026-03-31 07:26:40 +00:00
Lorenzo Gasparini
425d380d03 Bump fing_agent_api to 1.1.0 (#166855) 2026-03-31 07:26:39 +00:00
Denis Shulyaka
ff08335890 Fix OpenAI image generation with reasoning (#166827) 2026-03-31 07:26:37 +00:00
Florian
7170e3b232 Clamp surepetcare battery percentage to 0-100 (#166824)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-31 07:26:36 +00:00
Taylor Wilsdon
6111eaa9e9 Support vacation mode in Econet (#166659) 2026-03-31 07:26:34 +00:00
AlCalzone
e02a9fe61e Convert Z-Wave Opening state to separate Open/Closed and Tilted sensors (#166635)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-31 07:26:33 +00:00
Erik Montnemery
cba9bf5dc4 Add valve conditions (#166634) 2026-03-31 07:26:31 +00:00
Franck Nijhof
72a661f1fa Improve text action naming consistency (#166523)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-03-31 07:26:30 +00:00
Franck Nijhof
4168000155 Bump version to 2026.4.0b5 2026-03-30 08:56:27 +00:00
Manu
9d230b4f7c Bump habiticalib to 0.4.7 (#166772) 2026-03-30 08:56:21 +00:00
Matthias Alphart
745f32faa3 Update knx-frontend to 2026.3.28.223133 (#166764) 2026-03-30 08:56:20 +00:00
Jan Bouwhuis
112ad886c6 Revert mqtt vacuum segments support (#166761) 2026-03-30 08:56:19 +00:00
J. Nick Koston
8b0ec21a15 Bump aiohttp to 3.13.4 (#166756) 2026-03-30 08:56:18 +00:00
David Knowles
afce52a0f4 Bump pydrawise to 2026.3.0 (#166750) 2026-03-30 08:56:17 +00:00
Michael
7e4757c213 Bump aioimmich to 0.12.1 (#166746) 2026-03-30 08:56:16 +00:00
Louis Christ
d6dbcc8d82 Bump pyblu to 2.0.6 (#166738) 2026-03-30 08:56:15 +00:00
Åke Strandberg
fca87a2b8a Add missing code for miele washing machine (#166731) 2026-03-30 08:56:13 +00:00
Noah Husby
87e648b8b8 Bump aiorussound to 4.9.1 (#166718) 2026-03-30 08:56:12 +00:00
Will Moss
ada549489c Handle Oauth2 ImplementationUnavailableError in google_tasks (#166657)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:11 +00:00
Will Moss
15e13de2a6 Handle Oauth2 ImplementationUnavailableError in lyric (#166655)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:10 +00:00
Will Moss
dd74665622 Handle Oauth2 ImplementationUnavailableError in microbees (#166654)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:08 +00:00
Will Moss
ff8fc56696 Handle Oauth2 ImplementationUnavailableError in monzo (#166653)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:07 +00:00
Will Moss
2d8c903533 Handle Oauth2 ImplementationUnavailableError in iotty (#166652)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:06 +00:00
Will Moss
c1606f515b Handle Oauth2 ImplementationUnavailableError in google_sheets (#166651)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:05 +00:00
Will Moss
fac2702063 Handle Oauth2 ImplementationUnavailableError in google_mail (#166650)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:03 +00:00
Will Moss
76ae6958ed Handle Oauth2 ImplementationUnavailableError in google_assistant_sdk (#166649)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:02 +00:00
Will Moss
1876ed7d16 Handle Oauth2 ImplementationUnavailableError in geocaching (#166648)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:01 +00:00
Will Moss
08ef4e0de0 Handle Oauth2 ImplementationUnavailableError in gentex_homelink (#166646)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:00 +00:00
crash0verride11
a48db9d817 Correct Musiccast sound mode name (#166644)
Co-authored-by: crash0verride11 <3526616+crash0verride11@users.noreply.github.com>
Co-authored-by: jtjart <80978647+jtjart@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-30 08:55:59 +00:00
Will Moss
1334531740 Handle Oauth2 ImplementationUnavailableError in husqvarna_automower (#166633)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:55:58 +00:00
Erwin Douna
d769b16ada Add new OAuth exceptions to Neato (#166584)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 08:55:57 +00:00
Bram Kragten
c830320730 Bump version to 2026.4.0b4 2026-03-27 22:46:53 +01:00
Paul Bottein
336aa0f5df Update frontend to 20260325.2 (#166717) 2026-03-27 22:46:49 +01:00
Artur Pragacz
754291b34f Use legacy naming for entities (#166696) 2026-03-27 22:46:49 +01:00
Åke Strandberg
bbae0862b0 Add missing miele oven codes (#166690) 2026-03-27 22:46:48 +01:00
Åke Strandberg
6b7693b2fd Add missing miele program_id code (#166685) 2026-03-27 22:46:47 +01:00
Simone Chemelli
954926a05c Bump aioamazondevices to 13.3.1 (#166658) 2026-03-27 22:46:46 +01:00
Abílio Costa
71981f66ec Update idasen-ha to 2.6.5 (#166645) 2026-03-27 22:46:45 +01:00
Artur Pragacz
7f94f95ac9 Wait for device registry in entity registry loading (#166636) 2026-03-27 22:46:44 +01:00
Erik Montnemery
4ee3177c5d Add select conditions (#166612) 2026-03-27 22:46:43 +01:00
Erik Montnemery
9c1f9ca5c6 Add weather support to humidity conditions (#166599) 2026-03-27 22:46:42 +01:00
Franck Nijhof
cff4cf4d2c Bump version to 2026.4.0b3 2026-03-26 19:51:36 +00:00
Erik Montnemery
ee9d9781ee Add climate.is_hvac_mode condition (#166570) 2026-03-26 19:51:07 +00:00
Jamie Magee
1b972d4adc Remove tplink_lte integration (#166615) 2026-03-26 19:49:52 +00:00
Bram Kragten
72598479d5 Update frontend to 20260325.1 (#166614) 2026-03-26 19:49:50 +00:00
Erik Montnemery
02599a4a6e Add condition humidifier.is_mode (#166610) 2026-03-26 19:49:49 +00:00
Erik Montnemery
af9f351fce Restore support for number entities as limits in moisture conditions and triggers (#166608) 2026-03-26 19:49:47 +00:00
Erik Montnemery
ff79943776 Restore support for number entities as limits in battery conditions and triggers (#166607) 2026-03-26 19:49:46 +00:00
Erik Montnemery
e60048ef30 Add input_boolean support to switch conditions (#166602) 2026-03-26 19:49:45 +00:00
Erik Montnemery
24c0b22038 Add light.is_brightness condition (#166601) 2026-03-26 19:49:43 +00:00
Norbert Rittel
6f32a53742 Make siren conditions consistent with new wording (#166600) 2026-03-26 19:49:42 +00:00
Erik Montnemery
da9d1080d9 Remove number entity support from power triggers and conditions (#166597) 2026-03-26 19:49:41 +00:00
Erik Montnemery
2ea4d7913e Remove number entity support from moisture triggers and conditions (#166596) 2026-03-26 19:49:40 +00:00
Erik Montnemery
16999e3707 Remove number entity support from illuminance triggers and conditions (#166595) 2026-03-26 19:49:38 +00:00
Erik Montnemery
5c53b847dc Remove number entity support from humidity triggers and conditions (#166594) 2026-03-26 19:49:37 +00:00
Erik Montnemery
3afd763d16 Remove number entity support from battery triggers and conditions (#166593) 2026-03-26 19:49:35 +00:00
Abílio Costa
75a15ed24e Add todo to experimental triggers (#166591) 2026-03-26 19:49:34 +00:00
Ronald van der Meer
6d56597a2a Bump pooldose 0.9.0 (#166589) 2026-03-26 19:49:32 +00:00
Erik Montnemery
5872222213 Remove class NumericalDomainSpec (#166588) 2026-03-26 19:49:31 +00:00
reneboer
bd5c73fd7b Bump renault-api to 0.5.7 (#166586) 2026-03-26 19:49:30 +00:00
hanwg
d8a32dcf69 Add missing translations for Telegram bot (#166581)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-03-26 19:49:29 +00:00
Devin Slick
87cd90ab5d Bump lojack-api to 0.7.2 (#166560)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 19:45:06 +00:00
Tom
cb5b0c5b5e Verify Proxmox permissions when creating snapshots (#166547) 2026-03-26 19:45:04 +00:00
John Meyers
2fa16101f4 Update rainmachine solar radiation to reflect it is per day, not per … (#166040) 2026-03-26 19:45:03 +00:00
Franck Nijhof
6dd5c30b49 Bump version to 2026.4.0b2 2026-03-26 10:59:11 +00:00
AlCalzone
72f5a572eb Revert: Create repair issue for legacy Z-Wave Door state sensors that are still in use (#166583) 2026-03-26 10:58:55 +00:00
Erik Montnemery
d501d8cb28 Adjust some trigger and condition schemas (#166568) 2026-03-26 10:58:54 +00:00
Keilin Bickar
35c4b4ff5b Bump asyncsleepiq to 1.7.1 (#166552) 2026-03-26 10:58:53 +00:00
Keilin Bickar
f3e8ac5b8e Bump sense-energy to 0.14.0 (#166550) 2026-03-26 10:58:51 +00:00
tronikos
ab2bcd84c6 Add Google Drive backup upload progress (#166549) 2026-03-26 10:58:50 +00:00
Ariel Ebersberger
cdf7b013a9 Add battery triggers (#166258) 2026-03-26 10:58:48 +00:00
Erik Montnemery
eeba0467a1 Add trigger humidifier.mode_changed (#166241)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-03-26 10:58:47 +00:00
Franck Nijhof
43ca72bf7e Bump version to 2026.4.0b1 2026-03-26 00:01:26 +00:00
Franck Nijhof
aa9e279026 Improve conversation action naming consistency (#166542) 2026-03-26 00:01:16 +00:00
Franck Nijhof
9f3917830d Improve weather action naming consistency (#166540) 2026-03-26 00:01:15 +00:00
Franck Nijhof
c458bc2ee3 Improve dashboard action naming consistency (#166539) 2026-03-26 00:01:14 +00:00
Franck Nijhof
e0455629d7 Improve logger action naming consistency (#166538) 2026-03-26 00:01:12 +00:00
Franck Nijhof
b802dcba8d Improve group action naming consistency (#166537) 2026-03-26 00:01:11 +00:00
Franck Nijhof
7ff868e94c Improve water heater action naming consistency (#166535)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-26 00:01:10 +00:00
Franck Nijhof
44bd3e3d74 Improve device tracker action naming consistency (#166534) 2026-03-26 00:01:09 +00:00
Jordan Harvey
9d793ce1df Bump pyanglianwater to 3.1.2 (#166531) 2026-03-26 00:01:07 +00:00
Franck Nijhof
d8dee8fc91 Improve image action naming consistency (#166527) 2026-03-26 00:01:06 +00:00
Franck Nijhof
3c52acb825 Improve counter action naming consistency (#166526) 2026-03-26 00:01:04 +00:00
Franck Nijhof
cb195be6ad Improve automation action naming consistency (#166525) 2026-03-26 00:01:03 +00:00
Franck Nijhof
08f7bed679 Improve humidifier action naming consistency (#166524) 2026-03-26 00:01:02 +00:00
Erik Montnemery
744563c7a7 Speed up trigger tests (#166522) 2026-03-26 00:01:01 +00:00
Franck Nijhof
5d48801645 Improve valve action naming consistency (#166521) 2026-03-26 00:00:59 +00:00
Franck Nijhof
4211686c07 Improve script action naming consistency (#166517) 2026-03-26 00:00:58 +00:00
Franck Nijhof
98379c9642 Improve cloud action naming consistency (#166516) 2026-03-26 00:00:57 +00:00
Erik Montnemery
a3c9d35a13 Use NumericThresholdSelector in numeric conditions (#166507) 2026-03-26 00:00:56 +00:00
Erik Montnemery
5a7abc0a92 Add trigger water_heater.operation_mode_changed (#166450) 2026-03-26 00:00:54 +00:00
johanzander
ade73ec159 growatt_server: use human-readable labels in exception messages (#166024)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-03-26 00:00:53 +00:00
Franck Nijhof
6f7a5d9320 Bump version to 2026.4.0b0 2026-03-25 18:48:08 +00:00
608 changed files with 3235 additions and 14324 deletions

View File

@@ -1,228 +0,0 @@
---
name: raise-pull-request
description: |
Use this agent when creating a pull request for the Home Assistant core repository after completing implementation work. This agent automates the PR creation process including running tests, formatting checks, and proper checkbox handling.
model: inherit
color: green
tools: Read, Bash, Grep, Glob
---
You are an expert at creating pull requests for the Home Assistant core repository. You will automate the PR creation process with proper verification, formatting, testing, and checkbox handling.
**Execute each step in order. Do not skip steps.**
## Step 1: Gather Information
Run these commands in parallel to analyze the changes:
```bash
# Get current branch and remote
git branch --show-current
git remote -v | grep push
# Determine the best available dev reference
if git rev-parse --verify --quiet upstream/dev >/dev/null; then
BASE_REF="upstream/dev"
elif git rev-parse --verify --quiet origin/dev >/dev/null; then
BASE_REF="origin/dev"
elif git rev-parse --verify --quiet dev >/dev/null; then
BASE_REF="dev"
else
echo "Could not find upstream/dev, origin/dev, or local dev"
exit 1
fi
BASE_SHA="$(git merge-base "$BASE_REF" HEAD)"
echo "BASE_REF=$BASE_REF"
echo "BASE_SHA=$BASE_SHA"
# Get commit info for this branch vs dev
git log "${BASE_SHA}..HEAD" --oneline
# Check what files changed
git diff "${BASE_SHA}..HEAD" --name-only
# Check if test files were added/modified
git diff "${BASE_SHA}..HEAD" --name-only | grep -E "^tests/.*\.py$" || echo "NO_TESTS_CHANGED"
# Check if manifest.json changed
git diff "${BASE_SHA}..HEAD" --name-only | grep "manifest.json" || echo "NO_MANIFEST_CHANGED"
```
From the file paths, extract the **integration domain** from `homeassistant/components/{integration}/` or `tests/components/{integration}/`.
**Track results:**
- `BASE_REF`: the dev reference used for comparison
- `BASE_SHA`: the merge-base commit used for diff-based checks
- `TESTS_CHANGED`: true if test files were added or modified
- `MANIFEST_CHANGED`: true if manifest.json was modified
**If no suitable dev reference is available, STOP and tell the user to fetch `upstream/dev`, `origin/dev`, or a local `dev` branch before continuing.**
## Step 2: Run Code Quality Checks
Run `prek` to perform code quality checks (formatting, linting, hassfest, etc.) on the files changed since `BASE_SHA`:
```bash
prek run --from-ref "$BASE_SHA" --to-ref HEAD
```
**Track results:**
- `PREK_PASSED`: true if `prek run` exits with code 0
**If `prek` fails or is not available, STOP and report the failure to the user. Do not proceed with PR creation. If the failure appears to be an environment setup issue (e.g., missing tools, command not found, venv not activated), also point the user to https://developers.home-assistant.io/docs/development_environment.**
## Step 3: Stage Any Changes from Checks
If `prek` made any formatting or generated file changes, stage and commit them as a separate commit:
```bash
git status --porcelain
# If changes exist:
git add -A
git commit -m "Apply prek formatting and generated file updates"
```
## Step 4: Run Tests
Run pytest for the specific integration:
```bash
pytest tests/components/{integration} \
--timeout=60 \
--durations-min=1 \
--durations=0 \
-q
```
**Track results:**
- `TESTS_PASSED`: true if pytest exits with code 0
**If tests fail, STOP and report the failures to the user. Do not proceed with PR creation.**
## Step 5: Identify PR Metadata
Write a release-note-style PR title summarizing the change. The title becomes the release notes entry, so it should be a complete sentence fragment describing what changed in imperative mood.
**PR Title Examples by Type:**
| Type | Example titles |
|------|----------------|
| Bugfix | `Fix Hikvision NVR binary sensors not being detected` |
| | `Fix JSON serialization of time objects in anthropic tool results` |
| | `Fix config flow bug in Tesla Fleet` |
| Dependency | `Bump eheimdigital to 1.5.0` |
| | `Bump python-otbr-api to 2.7.1` |
| New feature | `Add asyncio-level timeout to Backblaze B2 uploads` |
| | `Add Nettleie optimization option` |
| Code quality | `Add exception translations to Teslemetry` |
| | `Improve test coverage of Tesla Fleet` |
| | `Refactor adguard tests to use proper fixtures for mocking` |
| | `Simplify entity init in Proxmox` |
## Step 6: Verify Development Checklist
Check each item from the [development checklist](https://developers.home-assistant.io/docs/development_checklist/):
| Item | How to verify |
|------|---------------|
| External libraries on PyPI | Check manifest.json requirements - all should be PyPI packages |
| Dependencies in requirements_all.txt | Only if dependency declarations changed (the `requirements` field in `manifest.json` or `requirements_all.txt`), run `python -m script.gen_requirements_all` |
| Codeowners updated | If this is a new integration, ensure its `manifest.json` includes a `codeowners` field with one or more GitHub usernames |
| No commented out code | Visually scan the diff for blocks of commented-out code |
**Track results:**
- `NO_COMMENTED_CODE`: true if no blocks of commented-out code found in the diff
- `DEPENDENCIES_CHANGED`: true if the diff changes the `requirements` field in `manifest.json` or changes `requirements_all.txt`
- `REQUIREMENTS_UPDATED`: true if `DEPENDENCIES_CHANGED` is true and requirements_all.txt was regenerated successfully; not applicable if `DEPENDENCIES_CHANGED` is false
- `CHECKLIST_PASSED`: true if all items above pass
## Step 7: Determine Type of Change
Select exactly ONE based on the changes. Mark the selected type with `[x]` and all others with `[ ]` (space):
| Type | Condition |
|------|-----------|
| Dependency upgrade | Only manifest.json/requirements changes |
| Bugfix | Fixes broken behavior, no new features |
| New integration | New folder in components/ |
| New feature | Adds capability to existing integration |
| Deprecation | Adds deprecation warnings for future breaking change |
| Breaking change | Removes or changes existing functionality |
| Code quality | Only refactoring or test additions, no functional change |
**Track results:**
- `CHANGE_TYPE`: the selected type (e.g., "Bugfix", "New feature", "Code quality", etc.)
**Important:** All seven type options must remain in the PR body. Only the selected type gets `[x]`, all others get `[ ]`.
## Step 8: Determine Checkbox States
Based on the verification steps above, determine checkbox states:
| Checkbox | Condition to tick |
|----------|-------------------|
| The code change is tested and works locally | Leave unchecked for the contributor to verify manually (this refers to manual testing, not unit tests) |
| Local tests pass | Tick only if `TESTS_PASSED` is true |
| I understand the code I am submitting and can explain how it works | Leave unchecked for the contributor to review and set manually |
| There is no commented out code | Tick only if `NO_COMMENTED_CODE` is true |
| Development checklist | Tick only if `CHECKLIST_PASSED` is true |
| Perfect PR recommendations | Tick only if the PR affects a single integration or closely related modules, represents one primary type of change, and has a clear, self-contained scope |
| Formatted using Ruff | Tick only if `PREK_PASSED` is true |
| Tests have been added | Tick only if `TESTS_CHANGED` is true AND the changes exercise new or changed functionality (not only cosmetic test changes) |
| Documentation added/updated | Tick if documentation PR created (or not applicable) |
| Manifest file fields filled out | Tick if `PREK_PASSED` is true (or not applicable) |
| Dependencies in requirements_all.txt | Tick only if `DEPENDENCIES_CHANGED` is false, or if `DEPENDENCIES_CHANGED` is true and `REQUIREMENTS_UPDATED` is true |
| Dependency changelog linked | Tick if dependency changelog linked in PR description (or not applicable) |
| Any generated code has been carefully reviewed | Leave unchecked for the contributor to review and set manually |
## Step 9: Breaking Change Section
**If `CHANGE_TYPE` is NOT "Breaking change" or "Deprecation": REMOVE the entire "## Breaking change" section from the PR body (including the heading).**
If `CHANGE_TYPE` IS "Breaking change" or "Deprecation", keep the `## Breaking change` section and describe:
- What breaks
- How users can fix it
- Why it was necessary
## Step 10: Push Branch and Create PR
```bash
# Get branch name and GitHub username
BRANCH=$(git branch --show-current)
PUSH_REMOTE=$(git config "branch.$BRANCH.remote" 2>/dev/null || git remote | head -1)
GITHUB_USER=$(gh api user --jq .login 2>/dev/null || git remote get-url "$PUSH_REMOTE" | sed -E 's#.*[:/]([^/]+)/([^/]+)(\.git)?$#\1#')
# Create PR (gh pr create pushes the branch automatically)
gh pr create --repo home-assistant/core --base dev \
--head "$GITHUB_USER:$BRANCH" \
--title "TITLE_HERE" \
--body "$(cat <<'EOF'
BODY_HERE
EOF
)"
```
### PR Body Template
Read the PR template from `.github/PULL_REQUEST_TEMPLATE.md` and use it as the basis for the PR body. **Do not hardcode the template — always read it from the file to stay in sync with upstream changes.**
Use any HTML comments (`<!-- ... -->`) in the template as guidance to understand what to fill in. For the final PR body sent to GitHub, keep the template text intact — do not delete any text from the template unless it explicitly instructs removal (e.g., the breaking change section when not applicable). Then fill in the sections:
1. **Breaking change section**: If the type is NOT "Breaking change" or "Deprecation", remove the entire `## Breaking change` section (heading and body). Otherwise, describe what breaks, how users can fix it, and why.
2. **Proposed change section**: Fill in a description of the change extracted from commit messages.
3. **Type of change**: Check exactly ONE checkbox matching the determined type from Step 7. Leave all others unchecked.
4. **Additional information**: Fill in any related issue numbers if known.
5. **Checklist**: Check boxes based on the conditions in Step 8. Leave manual-verification boxes unchecked for the contributor.
**Important:** Preserve all template structure, options, and link references exactly as they appear in the file — only modify checkbox states and fill in content sections.
## Step 11: Report Result
Provide the user with:
1. **PR URL** - The created pull request link
2. **Verification Summary** - Which checks passed/failed
3. **Unchecked Items** - List any checkboxes left unchecked and why
4. **User Action Required** - Remind user to:
- Review and set manual-verification checkboxes ("I understand the code..." and "Any generated code...") as applicable
- Consider reviewing two other open PRs
- Add any related issue numbers if applicable

View File

@@ -5,6 +5,14 @@ description: Review a GitHub pull request and provide feedback comments. Use whe
# Review GitHub Pull Request
## Preparation:
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
- Do NOT attempt any workarounds.
- Do NOT proceed with the review.
- ALERT about the failure and WAIT for instructions.
- This is a hard requirement - no exceptions.
## Follow these steps:
1. Use 'gh pr view' to get the PR details and description.
2. Use 'gh pr diff' to see all the changes in the PR.

View File

@@ -112,7 +112,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -123,7 +123,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package

View File

@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 3
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.5"
HA_SHORT_VERSION: "2026.4"
ADDITIONAL_PYTHON_VERSIONS: "[]"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
@@ -280,7 +280,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -301,7 +301,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
with:
extra-args: --all-files zizmor
@@ -364,7 +364,7 @@ jobs:
echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
key: >-
@@ -372,7 +372,7 @@ jobs:
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -384,7 +384,7 @@ jobs:
env.HA_SHORT_VERSION }}-
- name: Check if apt cache exists
id: cache-apt-check
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: |
@@ -430,7 +430,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -484,7 +484,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -515,7 +515,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -552,7 +552,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -643,7 +643,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -694,7 +694,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -747,7 +747,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -804,7 +804,7 @@ jobs:
echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -812,7 +812,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: .mypy_cache
key: >-
@@ -854,7 +854,7 @@ jobs:
- base
steps:
- name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -887,7 +887,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -930,7 +930,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -964,7 +964,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -1080,7 +1080,7 @@ jobs:
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1115,7 +1115,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -1238,7 +1238,7 @@ jobs:
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1275,7 +1275,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -1392,7 +1392,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
fail_ci_if_error: true
flags: full-suite
@@ -1421,7 +1421,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1455,7 +1455,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -1563,7 +1563,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
@@ -1591,7 +1591,7 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
report_type: test_results
fail_ci_if_error: true

View File

@@ -578,7 +578,6 @@ homeassistant.components.trmnl.*
homeassistant.components.tts.*
homeassistant.components.twentemilieu.*
homeassistant.components.unifi.*
homeassistant.components.unifi_access.*
homeassistant.components.unifiprotect.*
homeassistant.components.upcloud.*
homeassistant.components.update.*

16
CODEOWNERS generated
View File

@@ -222,8 +222,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/binary_sensor/ @home-assistant/core
/tests/components/binary_sensor/ @home-assistant/core
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
/homeassistant/components/blebox/ @bbx-a @swistakm @bkobus-bbx
/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx
/homeassistant/components/blebox/ @bbx-a @swistakm
/tests/components/blebox/ @bbx-a @swistakm
/homeassistant/components/blink/ @fronzbot
/tests/components/blink/ @fronzbot
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
@@ -739,8 +739,8 @@ build.json @home-assistant/supervisor
/tests/components/honeywell/ @rdfurman @mkmer
/homeassistant/components/hr_energy_qube/ @MattieGit
/tests/components/hr_energy_qube/ @MattieGit
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
/tests/components/html5/ @alexyao2015 @tr4nt0r
/homeassistant/components/html5/ @alexyao2015
/tests/components/html5/ @alexyao2015
/homeassistant/components/http/ @home-assistant/core
/tests/components/http/ @home-assistant/core
/homeassistant/components/huawei_lte/ @scop @fphammerle
@@ -1226,12 +1226,12 @@ build.json @home-assistant/supervisor
/tests/components/onewire/ @garbled1 @epenet
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
/tests/components/onkyo/ @arturpragacz @eclair4151
/homeassistant/components/onvif/ @jterrace
/tests/components/onvif/ @jterrace
/homeassistant/components/onvif/ @hunterjm @jterrace
/tests/components/onvif/ @hunterjm @jterrace
/homeassistant/components/open_meteo/ @frenck
/tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek @ab3lson
/tests/components/open_router/ @joostlek @ab3lson
/homeassistant/components/open_router/ @joostlek
/tests/components/open_router/ @joostlek
/homeassistant/components/opendisplay/ @g4bri3lDev
/tests/components/opendisplay/ @g4bri3lDev
/homeassistant/components/openerz/ @misialq

View File

@@ -1 +1 @@
"""The Actiontec integration."""
"""The actiontec component."""

View File

@@ -13,9 +13,6 @@ from homeassistant.helpers import (
config_entry_oauth2_flow,
device_registry as dr,
)
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from . import api
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
@@ -28,17 +25,11 @@ async def async_setup_entry(
hass: HomeAssistant, entry: AladdinConnectConfigEntry
) -> bool:
"""Set up Aladdin Connect Genie from a config entry."""
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)

View File

@@ -37,9 +37,6 @@
"close_door_failed": {
"message": "Failed to close the garage door"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"open_door_failed": {
"message": "Failed to open the garage door"
}

View File

@@ -3,10 +3,10 @@
from __future__ import annotations
import logging
from typing import Any, cast
from typing import Any
from adext import AdExt
from alarmdecoder.devices import Device, SerialDevice, SocketDevice
from alarmdecoder.devices import SerialDevice, SocketDevice
from alarmdecoder.util import NoDeviceError
import voluptuous as vol
@@ -102,21 +102,16 @@ class AlarmDecoderFlowHandler(ConfigFlow, domain=DOMAIN):
self._async_current_entries(), user_input, self.protocol
):
return self.async_abort(reason="already_configured")
connection: dict[str, Any] = {}
connection = {}
baud = None
device: Device
if self.protocol == PROTOCOL_SOCKET:
host = connection[CONF_HOST] = cast(str, user_input[CONF_HOST])
port = connection[CONF_PORT] = cast(int, user_input[CONF_PORT])
title: str = f"{host}:{port}"
host = connection[CONF_HOST] = user_input[CONF_HOST]
port = connection[CONF_PORT] = user_input[CONF_PORT]
title = f"{host}:{port}"
device = SocketDevice(interface=(host, port))
if self.protocol == PROTOCOL_SERIAL:
path = connection[CONF_DEVICE_PATH] = cast(
str, user_input[CONF_DEVICE_PATH]
)
baud = connection[CONF_DEVICE_BAUD] = cast(
int, user_input[CONF_DEVICE_BAUD]
)
path = connection[CONF_DEVICE_PATH] = user_input[CONF_DEVICE_PATH]
baud = connection[CONF_DEVICE_BAUD] = user_input[CONF_DEVICE_BAUD]
title = path
device = SerialDevice(interface=path)
@@ -137,7 +132,6 @@ class AlarmDecoderFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception during AlarmDecoder setup")
errors["base"] = "unknown"
schema: vol.Schema
if self.protocol == PROTOCOL_SOCKET:
schema = vol.Schema(
{

View File

@@ -45,21 +45,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
try:
await client.models.list(timeout=10.0)
except anthropic.AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
) from err
raise ConfigEntryAuthFailed(err) from err
except anthropic.AnthropicError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"message": err.message
if isinstance(err, anthropic.APIError)
else str(err)
},
) from err
raise ConfigEntryNotReady(err) from err
entry.runtime_data = client

View File

@@ -12,7 +12,6 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from .const import DOMAIN
from .entity import AnthropicBaseLLMEntity
if TYPE_CHECKING:
@@ -61,7 +60,7 @@ class AnthropicTaskEntity(
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="response_not_found"
"Last content in chat log is not an AssistantContent"
)
text = chat_log.content[-1].content or ""
@@ -79,9 +78,7 @@ class AnthropicTaskEntity(
err,
text,
)
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="json_parse_error"
) from err
raise HomeAssistantError("Error with Claude structured response") from err
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,

View File

@@ -71,16 +71,6 @@ CODE_EXECUTION_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
"claude-haiku-4-5",
"claude-opus-4-1",
"claude-opus-4-0",
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3-haiku",
]
DEPRECATED_MODELS = [
"claude-3",
]

View File

@@ -1,64 +0,0 @@
"""Diagnostics support for Anthropic."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from anthropic import __title__, __version__
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers import entity_registry as er
from .const import (
CONF_PROMPT,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
)
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from . import AnthropicConfigEntry
TO_REDACT = {
CONF_API_KEY,
CONF_PROMPT,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_TIMEZONE,
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AnthropicConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"client": f"{__title__}=={__version__}",
"title": entry.title,
"entry_id": entry.entry_id,
"entry_version": f"{entry.version}.{entry.minor_version}",
"state": entry.state.value,
"data": async_redact_data(entry.data, TO_REDACT),
"options": async_redact_data(entry.options, TO_REDACT),
"subentries": {
subentry.subentry_id: {
"title": subentry.title,
"subentry_type": subentry.subentry_type,
"data": async_redact_data(subentry.data, TO_REDACT),
}
for subentry in entry.subentries.values()
},
"entities": {
entity_entry.entity_id: entity_entry.extended_dict
for entity_entry in er.async_entries_for_config_entry(
er.async_get(hass), entry.entry_id
)
},
}

View File

@@ -19,8 +19,6 @@ from anthropic.types import (
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
CodeExecutionTool20250825Param,
CodeExecutionToolResultBlock,
CodeExecutionToolResultBlockParamContentParam,
Container,
ContentBlockParam,
DocumentBlockParam,
@@ -63,16 +61,15 @@ from anthropic.types import (
ToolUseBlockParam,
Usage,
WebSearchTool20250305Param,
WebSearchTool20260209Param,
WebSearchToolResultBlock,
WebSearchToolResultBlockParamContentParam,
)
from anthropic.types.bash_code_execution_tool_result_block_param import (
Content as BashCodeExecutionToolResultBlockParamContentParam,
Content as BashCodeExecutionToolResultContentParam,
)
from anthropic.types.message_create_params import MessageCreateParamsStreaming
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
Content as TextEditorCodeExecutionToolResultContentParam,
)
import voluptuous as vol
from voluptuous_openapi import convert
@@ -108,7 +105,6 @@ from .const import (
MIN_THINKING_BUDGET,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
)
@@ -228,22 +224,12 @@ def _convert_content(
},
),
}
elif content.tool_name == "code_execution":
tool_result_block = {
"type": "code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
CodeExecutionToolResultBlockParamContentParam,
content.tool_result,
),
}
elif content.tool_name == "bash_code_execution":
tool_result_block = {
"type": "bash_code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
BashCodeExecutionToolResultBlockParamContentParam,
content.tool_result,
BashCodeExecutionToolResultContentParam, content.tool_result
),
}
elif content.tool_name == "text_editor_code_execution":
@@ -251,7 +237,7 @@ def _convert_content(
"type": "text_editor_code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
TextEditorCodeExecutionToolResultBlockParamContentParam,
TextEditorCodeExecutionToolResultContentParam,
content.tool_result,
),
}
@@ -382,7 +368,6 @@ def _convert_content(
name=cast(
Literal[
"web_search",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
],
@@ -394,7 +379,6 @@ def _convert_content(
and tool_call.tool_name
in [
"web_search",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
]
@@ -417,11 +401,7 @@ def _convert_content(
messages[-1]["content"] = messages[-1]["content"][0]["text"]
else:
# Note: We don't pass SystemContent here as it's passed to the API as the prompt
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unexpected_chat_log_content",
translation_placeholders={"type": type(content).__name__},
)
raise HomeAssistantError("Unexpected content type in chat log")
return messages, container_id
@@ -463,9 +443,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
Each message could contain multiple blocks of the same type.
"""
if stream is None or not hasattr(stream, "__aiter__"):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
)
raise HomeAssistantError("Expected a stream of messages")
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
current_tool_args: str
@@ -486,7 +464,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input=response.content_block.input or {},
input={},
)
current_tool_args = ""
if response.content_block.name == output_tool:
@@ -548,14 +526,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input=response.content_block.input or {},
input={},
)
current_tool_args = ""
elif isinstance(
response.content_block,
(
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
),
@@ -611,13 +588,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
current_tool_block = None
continue
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_tool_block["input"] |= tool_args
current_tool_block["input"] = tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_block["id"],
tool_name=current_tool_block["name"],
tool_args=current_tool_block["input"],
tool_args=tool_args,
external=current_tool_block["type"] == "server_tool_use",
)
]
@@ -628,9 +605,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
chat_log.async_trace(_create_token_stats(input_usage, usage))
content_details.container = response.delta.container
if response.delta.stop_reason == "refusal":
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_refusal"
)
raise HomeAssistantError("Potential policy violation detected")
elif isinstance(response, RawMessageStopEvent):
if content_details:
content_details.delete_empty()
@@ -689,9 +664,7 @@ class AnthropicBaseLLMEntity(Entity):
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="system_message_not_found"
)
raise HomeAssistantError("First message must be a system message")
# System prompt with caching enabled
system_prompt: list[TextBlockParam] = [
@@ -752,34 +725,19 @@ class AnthropicBaseLLMEntity(Entity):
]
if options.get(CONF_CODE_EXECUTION):
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_WEB_SEARCH):
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
type="code_execution_20250825",
),
)
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
type="code_execution_20250825",
),
)
if options.get(CONF_WEB_SEARCH):
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_CODE_EXECUTION):
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
)
else:
web_search = WebSearchTool20260209Param(
name="web_search",
type="web_search_20260209",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
web_search = WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
web_search["user_location"] = {
"type": "approximate",
@@ -796,7 +754,7 @@ class AnthropicBaseLLMEntity(Entity):
last_message = messages[-1]
if last_message["role"] != "user":
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="user_message_not_found"
"Last message must be a user message to add attachments"
)
if isinstance(last_message["content"], str):
last_message["content"] = [
@@ -901,19 +859,11 @@ class AnthropicBaseLLMEntity(Entity):
except anthropic.AuthenticationError as err:
self.entry.async_start_reauth(self.hass)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
"Authentication error with Anthropic API, reauthentication required"
) from err
except anthropic.AnthropicError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"message": err.message
if isinstance(err, anthropic.APIError)
else str(err)
},
f"Sorry, I had a problem talking to Anthropic: {err}"
) from err
if not chat_log.unresponded_tool_results:
@@ -933,23 +883,15 @@ async def async_prepare_files_for_prompt(
for file_path, mime_type in files:
if not file_path.exists():
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="wrong_file_path",
translation_placeholders={"file_path": file_path.as_posix()},
)
raise HomeAssistantError(f"`{file_path}` does not exist")
if mime_type is None:
mime_type = guess_file_type(file_path)[0]
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="wrong_file_type",
translation_placeholders={
"file_path": file_path.as_posix(),
"mime_type": mime_type or "unknown",
},
"Only images and PDF are supported by the Anthropic API,"
f"`{file_path}` is not an image file or PDF"
)
if mime_type == "image/jpg":
mime_type = "image/jpeg"

View File

@@ -46,7 +46,7 @@ rules:
test-coverage: done
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
@@ -59,11 +59,17 @@ rules:
status: exempt
comment: |
No data updates.
docs-examples: done
docs-examples:
status: todo
comment: |
To give examples of how people use the integration
docs-known-limitations: done
docs-supported-devices: done
docs-supported-devices:
status: todo
comment: |
To write something about what models we support.
docs-supported-functions: done
docs-troubleshooting: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
@@ -82,7 +88,7 @@ rules:
comment: |
No entities disabled by default.
entity-translations: todo
exception-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: done
repair-issues: done

View File

@@ -161,9 +161,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
is None
or (subentry := entry.subentries.get(self._current_subentry_id)) is None
):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="subentry_not_found"
)
raise HomeAssistantError("Subentry not found")
updated_data = {
**subentry.data,
@@ -192,6 +190,4 @@ async def async_create_fix_flow(
"""Create flow."""
if issue_id == "model_deprecated":
return ModelDeprecatedRepairFlow()
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unknown_issue_id"
)
raise HomeAssistantError("Unknown issue ID")

View File

@@ -149,47 +149,6 @@
}
}
},
"exceptions": {
"api_authentication_error": {
"message": "Authentication error with Anthropic API: {message}. Reauthentication required."
},
"api_error": {
"message": "Anthropic API error: {message}."
},
"api_refusal": {
"message": "Potential policy violation detected."
},
"json_parse_error": {
"message": "Error with Claude structured response."
},
"response_not_found": {
"message": "Last content in chat log is not an AssistantContent."
},
"subentry_not_found": {
"message": "Subentry not found."
},
"system_message_not_found": {
"message": "First message must be a system message."
},
"unexpected_chat_log_content": {
"message": "Unexpected content type in chat log: {type}."
},
"unexpected_stream_object": {
"message": "Expected a stream of messages."
},
"unknown_issue_id": {
"message": "Unknown issue ID."
},
"user_message_not_found": {
"message": "Last message must be a user message to add attachments."
},
"wrong_file_path": {
"message": "`{file_path}` does not exist."
},
"wrong_file_type": {
"message": "Only images and PDF are supported by the Anthropic API, `{file_path}` ({mime_type}) is not an image file or PDF."
}
},
"issues": {
"model_deprecated": {
"fix_flow": {

View File

@@ -30,10 +30,9 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_CREDENTIALS,
@@ -43,12 +42,9 @@ from .const import (
SIGNAL_CONNECTED,
SIGNAL_DISCONNECTED,
)
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
DEFAULT_NAME_TV = "Apple TV"
DEFAULT_NAME_HP = "HomePod"
@@ -81,12 +77,6 @@ DEVICE_EXCEPTIONS = (
type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Apple TV component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool:
"""Set up a config entry for Apple TV."""
manager = AppleTVManager(hass, entry)

View File

@@ -9,5 +9,3 @@ CONF_START_OFF = "start_off"
SIGNAL_CONNECTED = "apple_tv_connected"
SIGNAL_DISCONNECTED = "apple_tv_disconnected"
ATTR_TEXT = "text"

View File

@@ -8,16 +8,5 @@
}
}
}
},
"services": {
"append_keyboard_text": {
"service": "mdi:keyboard"
},
"clear_keyboard_text": {
"service": "mdi:keyboard-off"
},
"set_keyboard_text": {
"service": "mdi:keyboard"
}
}
}

View File

@@ -1,130 +0,0 @@
"""Define services for the Apple TV integration."""
from __future__ import annotations
from pyatv.const import KeyboardFocusState
from pyatv.exceptions import NotSupportedError, ProtocolError
from pyatv.interface import AppleTV as AppleTVInterface
import voluptuous as vol
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, service
from .const import ATTR_TEXT, DOMAIN
SERVICE_SET_KEYBOARD_TEXT = "set_keyboard_text"
SERVICE_SET_KEYBOARD_TEXT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
vol.Required(ATTR_TEXT): cv.string,
}
)
SERVICE_APPEND_KEYBOARD_TEXT = "append_keyboard_text"
SERVICE_APPEND_KEYBOARD_TEXT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
vol.Required(ATTR_TEXT): cv.string,
}
)
SERVICE_CLEAR_KEYBOARD_TEXT = "clear_keyboard_text"
SERVICE_CLEAR_KEYBOARD_TEXT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
}
)
def _get_atv(call: ServiceCall) -> AppleTVInterface:
"""Get the AppleTVInterface for a service call."""
entry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
atv: AppleTVInterface | None = entry.runtime_data.atv
if atv is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="not_connected",
)
return atv
def _check_keyboard_focus(atv: AppleTVInterface) -> None:
"""Check that keyboard is focused on the device."""
try:
focus_state = atv.keyboard.text_focus_state
except NotSupportedError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="keyboard_not_available",
) from err
if focus_state != KeyboardFocusState.Focused:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="keyboard_not_focused",
)
async def _async_set_keyboard_text(call: ServiceCall) -> None:
"""Set text in the keyboard input field on an Apple TV."""
atv = _get_atv(call)
_check_keyboard_focus(atv)
try:
await atv.keyboard.text_set(call.data[ATTR_TEXT])
except ProtocolError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="keyboard_error",
) from err
async def _async_append_keyboard_text(call: ServiceCall) -> None:
"""Append text to the keyboard input field on an Apple TV."""
atv = _get_atv(call)
_check_keyboard_focus(atv)
try:
await atv.keyboard.text_append(call.data[ATTR_TEXT])
except ProtocolError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="keyboard_error",
) from err
async def _async_clear_keyboard_text(call: ServiceCall) -> None:
"""Clear text in the keyboard input field on an Apple TV."""
atv = _get_atv(call)
_check_keyboard_focus(atv)
try:
await atv.keyboard.text_clear()
except ProtocolError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="keyboard_error",
) from err
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Apple TV integration."""
hass.services.async_register(
DOMAIN,
SERVICE_SET_KEYBOARD_TEXT,
_async_set_keyboard_text,
schema=SERVICE_SET_KEYBOARD_TEXT_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_APPEND_KEYBOARD_TEXT,
_async_append_keyboard_text,
schema=SERVICE_APPEND_KEYBOARD_TEXT_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_CLEAR_KEYBOARD_TEXT,
_async_clear_keyboard_text,
schema=SERVICE_CLEAR_KEYBOARD_TEXT_SCHEMA,
)

View File

@@ -1,31 +0,0 @@
set_keyboard_text:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: apple_tv
text:
required: true
selector:
text:
append_keyboard_text:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: apple_tv
text:
required: true
selector:
text:
clear_keyboard_text:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: apple_tv

View File

@@ -69,20 +69,6 @@
}
}
},
"exceptions": {
"keyboard_error": {
"message": "An error occurred while sending text to the Apple TV"
},
"keyboard_not_available": {
"message": "Keyboard input is not supported by this device"
},
"keyboard_not_focused": {
"message": "No text input field is currently focused on the Apple TV"
},
"not_connected": {
"message": "Apple TV is not connected"
}
},
"options": {
"step": {
"init": {
@@ -92,45 +78,5 @@
"description": "Configure general device settings"
}
}
},
"services": {
"append_keyboard_text": {
"description": "Appends text to the currently focused text input field on an Apple TV.",
"fields": {
"config_entry_id": {
"description": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::description%]",
"name": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::name%]"
},
"text": {
"description": "The text to append.",
"name": "[%key:component::apple_tv::services::set_keyboard_text::fields::text::name%]"
}
},
"name": "Append keyboard text"
},
"clear_keyboard_text": {
"description": "Clears the text in the currently focused text input field on an Apple TV.",
"fields": {
"config_entry_id": {
"description": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::description%]",
"name": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::name%]"
}
},
"name": "Clear keyboard text"
},
"set_keyboard_text": {
"description": "Sets the text in the currently focused text input field on an Apple TV.",
"fields": {
"config_entry_id": {
"description": "The Apple TV to send text to.",
"name": "Apple TV"
},
"text": {
"description": "The text to set.",
"name": "Text"
}
},
"name": "Set keyboard text"
}
}
}

View File

@@ -2,8 +2,8 @@
import asyncio
from asyncio import timeout
from contextlib import AsyncExitStack
import logging
from typing import Any
from arcam.fmj import ConnectionFailed
from arcam.fmj.client import Client
@@ -54,31 +54,36 @@ async def _run_client(
client = runtime_data.client
coordinators = runtime_data.coordinators
def _listen(_: Any) -> None:
for coordinator in coordinators.values():
coordinator.async_notify_data_updated()
while True:
try:
async with AsyncExitStack() as stack:
async with timeout(interval):
await client.start()
stack.push_async_callback(client.stop)
async with timeout(interval):
await client.start()
_LOGGER.debug("Client connected %s", client.host)
_LOGGER.debug("Client connected %s", client.host)
try:
try:
for coordinator in coordinators.values():
await coordinator.state.start()
with client.listen(_listen):
for coordinator in coordinators.values():
await stack.enter_async_context(
coordinator.async_monitor_client()
)
coordinator.async_notify_connected()
await client.process()
finally:
_LOGGER.debug("Client disconnected %s", client.host)
finally:
await client.stop()
_LOGGER.debug("Client disconnected %s", client.host)
for coordinator in coordinators.values():
coordinator.async_notify_disconnected()
except ConnectionFailed:
pass
await asyncio.sleep(interval)
except TimeoutError:
continue
except Exception:
_LOGGER.exception("Unexpected exception, aborting arcam client")
return
await asyncio.sleep(interval)

View File

@@ -2,13 +2,11 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dataclasses import dataclass
import logging
from arcam.fmj import ConnectionFailed
from arcam.fmj.client import AmxDuetResponse, Client, ResponsePacket
from arcam.fmj.client import Client
from arcam.fmj.state import State
from homeassistant.config_entries import ConfigEntry
@@ -53,7 +51,7 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
)
self.client = client
self.state = State(client, zone)
self.update_in_progress = False
self.last_update_success = False
name = config_entry.title
unique_id = config_entry.unique_id or config_entry.entry_id
@@ -76,34 +74,24 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
async def _async_update_data(self) -> None:
"""Fetch data for manual refresh."""
try:
self.update_in_progress = True
await self.state.update()
except ConnectionFailed as err:
raise UpdateFailed(
f"Connection failed during update for zone {self.state.zn}"
) from err
finally:
self.update_in_progress = False
@callback
def _async_notify_packet(self, packet: ResponsePacket | AmxDuetResponse) -> None:
"""Packet callback to detect changes to state."""
if (
not isinstance(packet, ResponsePacket)
or packet.zn != self.state.zn
or self.update_in_progress
):
return
def async_notify_data_updated(self) -> None:
"""Notify that new data has been received from the device."""
self.async_set_updated_data(None)
@callback
def async_notify_connected(self) -> None:
"""Handle client connected."""
self.hass.async_create_task(self.async_refresh())
@callback
def async_notify_disconnected(self) -> None:
"""Handle client disconnected."""
self.last_update_success = False
self.async_update_listeners()
@asynccontextmanager
async def async_monitor_client(self) -> AsyncGenerator[None]:
"""Monitor a client and state for changes while connected."""
async with self.state:
self.hass.async_create_task(self.async_refresh())
try:
with self.client.listen(self._async_notify_packet):
yield
finally:
self.hass.async_create_task(self.async_refresh())

View File

@@ -26,8 +26,3 @@ class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
if description is not None:
self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
self.entity_description = description
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.client.connected

View File

@@ -1 +1 @@
"""The Arris TG2492LG integration."""
"""The Arris TG2492LG component."""

View File

@@ -1 +1 @@
"""The Aruba integration."""
"""The aruba component."""

View File

@@ -142,13 +142,6 @@ class WellKnownOAuthInfoView(HomeAssistantView):
"authorization_endpoint": f"{url_prefix}/auth/authorize",
"token_endpoint": f"{url_prefix}/auth/token",
"revocation_endpoint": f"{url_prefix}/auth/revoke",
# Home Assistant already accepts URL-based client_ids via
# IndieAuth without prior registration, which is compatible with
# draft-ietf-oauth-client-id-metadata-document. This flag
# advertises that support to encourage clients to use it. The
# metadata document is not actually fetched as IndieAuth doesn't
# require it.
"client_id_metadata_document_supported": True,
"response_types_supported": ["code"],
"service_documentation": (
"https://developers.home-assistant.io/docs/auth_api"

View File

@@ -1 +1 @@
"""The Bbox integration."""
"""The bbox component."""

View File

@@ -1 +1 @@
"""The Bitcoin integration."""
"""The bitcoin component."""

View File

@@ -1,7 +1,7 @@
{
"domain": "blebox",
"name": "BleBox devices",
"codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"],
"codeowners": ["@bbx-a", "@swistakm"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/blebox",
"integration_type": "device",

View File

@@ -1 +1 @@
"""The BlinkStick integration."""
"""The blinksticklight component."""

View File

@@ -1,4 +1,4 @@
"""Support for BlinkStick lights."""
"""Support for Blinkstick lights."""
# mypy: ignore-errors
from __future__ import annotations
@@ -40,7 +40,7 @@ def setup_platform(
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up BlinkStick device specified by serial number."""
"""Set up Blinkstick device specified by serial number."""
name = config[CONF_NAME]
serial = config[CONF_SERIAL]

View File

@@ -52,9 +52,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Rotate the access token."""
access_tokens.append(hex(_RND.getrandbits(256))[2:])
async_track_time_interval(
hass, _rotate_token, TOKEN_CHANGE_INTERVAL, cancel_on_shutdown=True
)
async_track_time_interval(hass, _rotate_token, TOKEN_CHANGE_INTERVAL)
hass.http.register_view(BrandsIntegrationView(hass))
hass.http.register_view(BrandsHardwareView(hass))

View File

@@ -578,13 +578,13 @@ class CalendarEntity(Entity):
return STATE_OFF
@callback
def _async_write_ha_state(self) -> None:
def async_write_ha_state(self) -> None:
"""Write the state to the state machine.
This sets up listeners to handle state transitions for start or end of
the current or upcoming event.
"""
super()._async_write_ha_state()
super().async_write_ha_state()
if self._alarm_unsubs is None:
self._alarm_unsubs = []
_LOGGER.debug(

View File

@@ -760,12 +760,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return CameraCapabilities(frontend_stream_types)
@callback
def _async_write_ha_state(self) -> None:
def async_write_ha_state(self) -> None:
"""Write the state to the state machine.
Schedules async_refresh_providers if support of streams have changed.
"""
super()._async_write_ha_state()
super().async_write_ha_state()
if self.__supports_stream != (
supports_stream := self.supported_features & CameraEntityFeature.STREAM
):

View File

@@ -11,12 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT,
Platform.SELECT,
]
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:

View File

@@ -12,7 +12,5 @@ SORTED_BRIGHTNESS_LEVELS = sorted(BRIGHTNESS_LEVELS)
DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0]
DIMMING_TIME_OPTIONS: tuple[str, ...] = tuple(str(m) for m in DIMMING_TIME_MINUTES)
# Interval between periodic state polls to catch externally-triggered changes.
STATE_POLL_INTERVAL = timedelta(seconds=30)

View File

@@ -19,7 +19,7 @@ from homeassistant.components.bluetooth.active_update_coordinator import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from .const import SORTED_BRIGHTNESS_LEVELS, STATE_POLL_INTERVAL
from .const import STATE_POLL_INTERVAL
_LOGGER = logging.getLogger(__name__)
@@ -51,15 +51,6 @@ class CasperGlowCoordinator(ActiveBluetoothDataUpdateCoordinator[None]):
)
self.title = title
# The device API couples brightness and dimming time into a
# single command (set_brightness_and_dimming_time), so both
# values must be tracked here for cross-entity use.
self.last_brightness_pct: int = (
device.state.brightness_level
if device.state.brightness_level is not None
else SORTED_BRIGHTNESS_LEVELS[0]
)
@callback
def _needs_poll(
self,

View File

@@ -1,31 +0,0 @@
"""Diagnostics support for the Casper Glow integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components import bluetooth
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import CasperGlowConfigEntry
SERVICE_INFO_TO_REDACT = frozenset({"address", "name", "source", "device"})
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: CasperGlowConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
service_info = bluetooth.async_last_service_info(
hass, coordinator.device.address, connectable=True
)
return {
"service_info": async_redact_data(
service_info.as_dict() if service_info else None,
SERVICE_INFO_TO_REDACT,
),
}

View File

@@ -12,11 +12,6 @@
"resume": {
"default": "mdi:play"
}
},
"select": {
"dimming_time": {
"default": "mdi:timer-outline"
}
}
}
}

View File

@@ -71,7 +71,6 @@ class CasperGlowLight(CasperGlowEntity, LightEntity):
self._attr_color_mode = ColorMode.BRIGHTNESS
if state.brightness_level is not None:
self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level)
self.coordinator.last_brightness_pct = state.brightness_level
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
@@ -98,7 +97,6 @@ class CasperGlowLight(CasperGlowEntity, LightEntity):
)
)
self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct)
self.coordinator.last_brightness_pct = brightness_pct
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""

View File

@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"loggers": ["pycasperglow"],
"quality_scale": "silver",
"requirements": ["pycasperglow==1.2.0"]
"requirements": ["pycasperglow==1.1.0"]
}

View File

@@ -39,7 +39,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: No network discovery.
@@ -52,10 +52,8 @@ rules:
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class:
status: exempt
comment: No applicable device classes for binary_sensor, button, light, or select entities.
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done

View File

@@ -1,92 +0,0 @@
"""Casper Glow integration select platform for dimming time."""
from __future__ import annotations
from pycasperglow import GlowState
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DIMMING_TIME_OPTIONS
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: CasperGlowConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the select platform for Casper Glow."""
async_add_entities([CasperGlowDimmingTimeSelect(entry.runtime_data)])
class CasperGlowDimmingTimeSelect(CasperGlowEntity, SelectEntity, RestoreEntity):
"""Select entity for Casper Glow dimming time."""
_attr_translation_key = "dimming_time"
_attr_entity_category = EntityCategory.CONFIG
_attr_options = list(DIMMING_TIME_OPTIONS)
_attr_unit_of_measurement = UnitOfTime.MINUTES
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize the dimming time select entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_dimming_time"
@property
def current_option(self) -> str | None:
"""Return the currently selected dimming time from the coordinator."""
if self.coordinator.last_dimming_time_minutes is None:
return None
return str(self.coordinator.last_dimming_time_minutes)
async def async_added_to_hass(self) -> None:
"""Restore last known dimming time and register state update callback."""
await super().async_added_to_hass()
if self.coordinator.last_dimming_time_minutes is None and (
last_state := await self.async_get_last_state()
):
if last_state.state in DIMMING_TIME_OPTIONS:
self.coordinator.last_dimming_time_minutes = int(last_state.state)
self.async_on_remove(
self._device.register_callback(self._async_handle_state_update)
)
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
if state.brightness_level is not None:
self.coordinator.last_brightness_pct = state.brightness_level
if (
state.configured_dimming_time_minutes is not None
and self.coordinator.last_dimming_time_minutes is None
):
self.coordinator.last_dimming_time_minutes = (
state.configured_dimming_time_minutes
)
# Dimming time is not part of the device state
# that is provided via BLE update. Therefore
# we need to trigger a state update for the select entity
# to update the current state.
self.async_write_ha_state()
async def async_select_option(self, option: str) -> None:
"""Set the dimming time."""
await self._async_command(
self._device.set_brightness_and_dimming_time(
self.coordinator.last_brightness_pct, int(option)
)
)
self.coordinator.last_dimming_time_minutes = int(option)
# Dimming time is not part of the device state
# that is provided via BLE update. Therefore
# we need to trigger a state update for the select entity
# to update the current state.
self.async_write_ha_state()

View File

@@ -39,11 +39,6 @@
"resume": {
"name": "Resume dimming"
}
},
"select": {
"dimming_time": {
"name": "Dimming time"
}
}
},
"exceptions": {

View File

@@ -9,6 +9,7 @@ from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE
from homeassistant.components.climate import (
DOMAIN as CLIMATE_DOMAIN,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -91,7 +92,7 @@ async def async_setup_entry(
entities: list[ClimateEntity] = []
for device in coordinator.data[CLIMATE].values():
values = load_api_data(device, "climate")
values = load_api_data(device, CLIMATE_DOMAIN)
if values[0] == 0 and values[4] == 0:
# No climate data, device is only a humidifier/dehumidifier
@@ -139,7 +140,7 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
def _update_attributes(self) -> None:
"""Update class attributes."""
device = self.coordinator.data[CLIMATE][self._device.index]
values = load_api_data(device, "climate")
values = load_api_data(device, CLIMATE_DOMAIN)
_active = values[1]
_mode = values[2] # Values from API: "O", "L", "U"

View File

@@ -9,6 +9,7 @@ from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE
from homeassistant.components.humidifier import (
DOMAIN as HUMIDIFIER_DOMAIN,
MODE_AUTO,
MODE_NORMAL,
HumidifierAction,
@@ -67,7 +68,7 @@ async def async_setup_entry(
entities: list[ComelitHumidifierEntity] = []
for device in coordinator.data[CLIMATE].values():
values = load_api_data(device, "humidifier")
values = load_api_data(device, HUMIDIFIER_DOMAIN)
if values[0] == 0 and values[4] == 0:
# No humidity data, device is only a climate
@@ -141,7 +142,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
def _update_attributes(self) -> None:
"""Update class attributes."""
device = self.coordinator.data[CLIMATE][self._device.index]
values = load_api_data(device, "humidifier")
values = load_api_data(device, HUMIDIFIER_DOMAIN)
_active = values[1]
_mode = values[2] # Values from API: "O", "L", "U"

View File

@@ -113,6 +113,9 @@
"humidity_while_off": {
"message": "Cannot change humidity while off"
},
"invalid_clima_data": {
"message": "Invalid 'clima' data"
},
"update_failed": {
"message": "Failed to update data: {error}"
}

View File

@@ -2,12 +2,13 @@
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import TYPE_CHECKING, Any, Concatenate, Literal
from typing import TYPE_CHECKING, Any, Concatenate
from aiocomelit.api import ComelitSerialBridgeObject
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from aiohttp import ClientSession, CookieJar
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -29,19 +30,17 @@ async def async_client_session(hass: HomeAssistant) -> ClientSession:
)
def load_api_data(
device: ComelitSerialBridgeObject,
domain: Literal["climate", "humidifier"],
) -> list[Any]:
def load_api_data(device: ComelitSerialBridgeObject, domain: str) -> list[Any]:
"""Load data from the API."""
# This function is called when the data is loaded from the API.
# For climate and humidifier device.val is always a list.
if TYPE_CHECKING:
assert isinstance(device.val, list)
# This function is called when the data is loaded from the API
if not isinstance(device.val, list):
raise HomeAssistantError(
translation_domain=domain, translation_key="invalid_clima_data"
)
# CLIMATE has a 2 item tuple:
# - first for Clima
# - second for Humidifier
return device.val[0] if domain == "climate" else device.val[1]
return device.val[0] if domain == CLIMATE_DOMAIN else device.val[1]
async def cleanup_stale_entity(

View File

@@ -210,7 +210,7 @@ def websocket_update_entity(
)
return
changes: dict[str, Any] = {}
changes = {}
for key in (
"area_id",

View File

@@ -44,18 +44,18 @@ class DemoRemote(RemoteEntity):
return {"last_command_sent": self._last_command_sent}
return None
async def async_turn_on(self, **kwargs: Any) -> None:
def turn_on(self, **kwargs: Any) -> None:
"""Turn the remote on."""
self._attr_is_on = True
self.async_write_ha_state()
self.schedule_update_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
def turn_off(self, **kwargs: Any) -> None:
"""Turn the remote off."""
self._attr_is_on = False
self.async_write_ha_state()
self.schedule_update_ha_state()
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
def send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send a command to a device."""
for com in command:
self._last_command_sent = com
self.async_write_ha_state()
self.schedule_update_ha_state()

View File

@@ -61,12 +61,12 @@ class DemoSwitch(SwitchEntity):
name=device_name,
)
async def async_turn_on(self, **kwargs: Any) -> None:
def turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
self._attr_is_on = True
self.async_write_ha_state()
self.schedule_update_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
def turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
self._attr_is_on = False
self.async_write_ha_state()
self.schedule_update_ha_state()

View File

@@ -1 +1 @@
"""The Denon Network Receivers integration."""
"""The denon component."""

View File

@@ -1,4 +1,4 @@
"""The Denon AVR Network Receivers integration."""
"""The denonavr component."""
import logging

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from devolo_home_control_api.devices.zwave import Zwave
from devolo_home_control_api.homecontrol import HomeControl
@@ -190,8 +188,6 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity):
def sync_callback(self, message: tuple) -> None:
"""Update the consumption sensor state."""
if message[0] == self._attr_unique_id:
if TYPE_CHECKING:
assert self._attr_unique_id is not None
self._value = getattr(
self._device_instance.consumption_property[self._attr_unique_id],
self._sensor_type,

View File

@@ -353,10 +353,10 @@ class DlnaDmrEntity(MediaPlayerEntity):
# Device was de/re-connected, state might have changed
self.async_write_ha_state()
def _async_write_ha_state(self) -> None:
def async_write_ha_state(self) -> None:
"""Write the state."""
self._attr_supported_features = self._supported_features()
super()._async_write_ha_state()
super().async_write_ha_state()
async def _device_connect(self, location: str) -> None:
"""Connect to the device now that it's available."""

View File

@@ -1,4 +1,4 @@
"""The DNS IP integration."""
"""The dnsip component."""
from __future__ import annotations
@@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload DNS IP config entry."""
"""Unload dnsip config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -25,7 +25,7 @@ def _fix_device_registry_identifiers(
if old_identifier not in device_entry.identifiers: # type: ignore[comparison-overlap]
continue
new_identifiers = device_entry.identifiers.copy()
new_identifiers.discard(old_identifier)
new_identifiers.discard(old_identifier) # type: ignore[arg-type]
new_identifiers.add((DOMAIN, entry.data["station"]))
device_registry.async_update_device(
device_entry.id, new_identifiers=new_identifiers

View File

@@ -1 +1 @@
"""The EBox integration."""
"""The ebox component."""

View File

@@ -1 +1 @@
"""The Edimax integration."""
"""The edimax component."""

View File

@@ -273,7 +273,7 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
continue
# Build kwargs common to both modes
kwargs: dict[str, Any] = base_stream_params | {
kwargs = base_stream_params | {
"text": text,
}

View File

@@ -293,7 +293,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> boo
elk_temp_unit = elk.panel.temperature_units
if elk_temp_unit == "C":
temperature_unit = UnitOfTemperature.CELSIUS # type: ignore[unreachable]
temperature_unit = UnitOfTemperature.CELSIUS
else:
temperature_unit = UnitOfTemperature.FAHRENHEIT
config["temperature_unit"] = temperature_unit

View File

@@ -361,8 +361,7 @@ class EvoController(EvoClimateEntity):
async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None:
"""Process a service request (system mode) for a controller.
Data validation is not required here; it is performed upstream by the service
handler (service schema plus runtime checks).
Data validation is not required, it will have been done upstream.
"""
if service == EvoService.RESET_SYSTEM:
@@ -388,16 +387,9 @@ class EvoController(EvoClimateEntity):
) -> None:
"""Set a Controller to any of its native operating modes."""
until = dt_util.as_utc(until) if until else None
try:
await self.coordinator.call_client_api(
self._evo_device.set_mode(mode, until=until)
)
except evo.InvalidSystemModeError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_system_mode",
translation_placeholders={"error": str(err)},
) from err
await self.coordinator.call_client_api(
self._evo_device.set_mode(mode, until=until)
)
@property
def hvac_mode(self) -> HVACMode:

View File

@@ -139,9 +139,6 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator):
try:
result = await client_api
except ec2.InvalidSystemModeError:
raise
except ec2.ApiRequestFailedError as err:
self.logger.error(err)
return None

View File

@@ -5,18 +5,17 @@ from __future__ import annotations
from datetime import timedelta
from typing import Any, Final
from evohomeasync2 import ControlSystem
from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE
from evohomeasync2.schemas.const import (
S2_DURATION as SZ_DURATION,
S2_PERIOD as SZ_PERIOD,
SystemMode as EvoSystemMode,
)
import voluptuous as vol
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import verify_domain_control
@@ -24,19 +23,8 @@ from homeassistant.helpers.service import verify_domain_control
from .const import ATTR_DURATION, ATTR_PERIOD, ATTR_SETPOINT, DOMAIN, EvoService
from .coordinator import EvoDataUpdateCoordinator
# System service schemas (registered as domain services)
SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
# unsupported modes are rejected at runtime with ServiceValidationError
vol.Required(ATTR_MODE): cv.string, # avoid vol.In(SystemMode)
vol.Exclusive(ATTR_DURATION, "temporary"): vol.All(
cv.time_period,
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),
),
vol.Exclusive(ATTR_PERIOD, "temporary"): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=1), max=timedelta(days=99)),
),
}
# system mode schemas are built dynamically when the services are registered
# because supported modes can vary for edge-case systems
# Zone service schemas (registered as entity services)
SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
@@ -71,56 +59,16 @@ def _register_zone_entity_services(hass: HomeAssistant) -> None:
)
def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) -> None:
"""Validate that a set_system_mode service call is properly formed."""
mode = data[ATTR_MODE]
tcs_modes = {m[SZ_SYSTEM_MODE]: m for m in tcs.allowed_system_modes}
# Validation occurs here, instead of in the library, because it uses a slightly
# different schema (until instead of duration/period) for the method invoked
# via this service call
if (mode_info := tcs_modes.get(mode)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mode_not_supported",
translation_placeholders={ATTR_MODE: mode},
)
# voluptuous schema ensures that duration and period are not both present
if not mode_info[SZ_CAN_BE_TEMPORARY]:
if ATTR_DURATION in data or ATTR_PERIOD in data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mode_cant_be_temporary",
translation_placeholders={ATTR_MODE: mode},
)
return
timing_mode = mode_info.get(SZ_TIMING_MODE) # will not be None, as can_be_temporary
if timing_mode == SZ_DURATION and ATTR_PERIOD in data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mode_cant_have_period",
translation_placeholders={ATTR_MODE: mode},
)
if timing_mode == SZ_PERIOD and ATTR_DURATION in data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mode_cant_have_duration",
translation_placeholders={ATTR_MODE: mode},
)
@callback
def setup_service_functions(
hass: HomeAssistant, coordinator: EvoDataUpdateCoordinator
) -> None:
"""Set up the service handlers for Evohome systems."""
"""Set up the service handlers for the system/zone operating modes.
Not all Honeywell TCC-compatible systems support all operating modes. In addition,
each mode will require any of four distinct service schemas. This has to be
enumerated before registering the appropriate handlers.
"""
@verify_domain_control(DOMAIN)
async def force_refresh(call: ServiceCall) -> None:
@@ -129,14 +77,7 @@ def setup_service_functions(
@verify_domain_control(DOMAIN)
async def set_system_mode(call: ServiceCall) -> None:
"""Set the Evohome system mode or reset the system."""
# No additional validation for RESET_SYSTEM here, as the library method invoked
# via that service call may be able to emulate the reset even if the system
# doesn't support AutoWithReset natively
if call.service == EvoService.SET_SYSTEM_MODE:
_validate_set_system_mode_params(coordinator.tcs, call.data)
"""Set the system mode."""
payload = {
"unique_id": coordinator.tcs.id,
@@ -145,14 +86,59 @@ def setup_service_functions(
}
async_dispatcher_send(hass, DOMAIN, payload)
assert coordinator.tcs is not None # mypy
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
hass.services.async_register(
DOMAIN,
EvoService.SET_SYSTEM_MODE,
set_system_mode,
schema=vol.Schema(SET_SYSTEM_MODE_SCHEMA),
)
# Enumerate which operating modes are supported by this system
modes = list(coordinator.tcs.allowed_system_modes)
system_mode_schemas = []
modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET]
# Permanent-only modes will use this schema
perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]]
if perm_modes: # any of: "Auto", "HeatingOff": permanent only
schema = vol.Schema({vol.Required(ATTR_MODE): vol.In(perm_modes)})
system_mode_schemas.append(schema)
modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]]
# These modes are set for a number of hours (or indefinitely): use this schema
temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_DURATION]
if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours
schema = vol.Schema(
{
vol.Required(ATTR_MODE): vol.In(temp_modes),
vol.Optional(ATTR_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),
),
}
)
system_mode_schemas.append(schema)
# These modes are set for a number of days (or indefinitely): use this schema
temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_PERIOD]
if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days
schema = vol.Schema(
{
vol.Required(ATTR_MODE): vol.In(temp_modes),
vol.Optional(ATTR_PERIOD): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=1), max=timedelta(days=99)),
),
}
)
system_mode_schemas.append(schema)
if system_mode_schemas:
hass.services.async_register(
DOMAIN,
EvoService.SET_SYSTEM_MODE,
set_system_mode,
schema=vol.Schema(vol.Any(*system_mode_schemas)),
)
_register_zone_entity_services(hass)

View File

@@ -1,20 +1,5 @@
{
"exceptions": {
"invalid_system_mode": {
"message": "The requested system mode is not supported: {error}"
},
"mode_cant_be_temporary": {
"message": "The mode `{mode}` does not support `duration` or `period`"
},
"mode_cant_have_duration": {
"message": "The mode `{mode}` does not support `duration`; use `period` instead"
},
"mode_cant_have_period": {
"message": "The mode `{mode}` does not support `period`; use `duration` instead"
},
"mode_not_supported": {
"message": "The mode `{mode}` is not supported by this controller"
},
"zone_only_service": {
"message": "Only zones support the `{service}` action"
}

View File

@@ -1 +1 @@
"""The Fail2Ban integration."""
"""The fail2ban component."""

View File

@@ -1 +1 @@
"""The Fido integration."""
"""The fido component."""

View File

@@ -4,12 +4,9 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from . import api
from .const import DOMAIN, FitbitScope
from .const import FitbitScope
from .coordinator import FitbitConfigEntry, FitbitData, FitbitDeviceCoordinator
from .exceptions import FitbitApiException, FitbitAuthException
from .model import config_from_entry_data
@@ -19,17 +16,11 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool:
"""Set up fitbit from a config entry."""
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
fitbit_api = api.OAuthFitbitApi(
hass, session, unit_system=entry.data.get("unit_system")

View File

@@ -121,10 +121,5 @@
"name": "Water"
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -1 +1 @@
"""Fortinet FortiOS integration."""
"""Fortinet FortiOS components."""

View File

@@ -1,6 +1,6 @@
"""Support to use FortiOS device like FortiGate as device tracker.
This FortiOS integration provides a device_tracker platform.
This component is part of the device_tracker platform.
"""
from __future__ import annotations

View File

@@ -3,7 +3,7 @@
import asyncio
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from .coordinator import (
FreshrConfigEntry,
@@ -21,10 +21,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo
await devices_coordinator.async_config_entry_first_refresh()
readings: dict[str, FreshrReadingsCoordinator] = {
device_id: FreshrReadingsCoordinator(
device.id: FreshrReadingsCoordinator(
hass, entry, device, devices_coordinator.client
)
for device_id, device in devices_coordinator.data.items()
for device in devices_coordinator.data
}
await asyncio.gather(
*(
@@ -38,35 +38,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo
readings=readings,
)
known_devices: set[str] = set(readings)
@callback
def _handle_coordinator_update() -> None:
current = set(devices_coordinator.data)
removed_ids = known_devices - current
if removed_ids:
known_devices.difference_update(removed_ids)
for device_id in removed_ids:
entry.runtime_data.readings.pop(device_id, None)
new_ids = current - known_devices
if not new_ids:
return
known_devices.update(new_ids)
for device_id in new_ids:
device = devices_coordinator.data[device_id]
readings_coordinator = FreshrReadingsCoordinator(
hass, entry, device, devices_coordinator.client
)
entry.runtime_data.readings[device_id] = readings_coordinator
hass.async_create_task(
readings_coordinator.async_refresh(),
name=f"freshr_readings_refresh_{device_id}",
)
entry.async_on_unload(
devices_coordinator.async_add_listener(_handle_coordinator_update)
)
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True

View File

@@ -30,31 +30,22 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
async def _validate_input(self, username: str, password: str) -> str | None:
"""Validate credentials, returning an error key or None on success."""
client = FreshrClient(session=async_get_clientsession(self.hass))
try:
await client.login(username, password)
except LoginError:
return "invalid_auth"
except ClientError:
return "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
return "unknown"
return 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:
error = await self._validate_input(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
if error:
errors["base"] = error
client = FreshrClient(session=async_get_clientsession(self.hass))
try:
await client.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
except LoginError:
errors["base"] = "invalid_auth"
except ClientError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
self._abort_if_unique_id_configured()
@@ -67,34 +58,6 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
reconfigure_entry = self._get_reconfigure_entry()
errors: dict[str, str] = {}
if user_input is not None:
error = await self._validate_input(
reconfigure_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
)
if error:
errors["base"] = error
else:
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
description_placeholders={
CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME]
},
errors=errors,
)
async def async_step_reauth(
self, _user_input: Mapping[str, Any]
) -> ConfigFlowResult:
@@ -109,11 +72,18 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
reauth_entry = self._get_reauth_entry()
if user_input is not None:
error = await self._validate_input(
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
)
if error:
errors["base"] = error
client = FreshrClient(session=async_get_clientsession(self.hass))
try:
await client.login(
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
)
except LoginError:
errors["base"] = "invalid_auth"
except ClientError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,

View File

@@ -12,7 +12,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -33,7 +32,7 @@ class FreshrData:
type FreshrConfigEntry = ConfigEntry[FreshrData]
class FreshrDevicesCoordinator(DataUpdateCoordinator[dict[str, DeviceSummary]]):
class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
"""Coordinator that refreshes the device list once an hour."""
config_entry: FreshrConfigEntry
@@ -49,7 +48,7 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[dict[str, DeviceSummary]]):
)
self.client = FreshrClient(session=async_create_clientsession(hass))
async def _async_update_data(self) -> dict[str, DeviceSummary]:
async def _async_update_data(self) -> list[DeviceSummary]:
"""Fetch the list of devices from the Fresh-r API."""
username = self.config_entry.data[CONF_USERNAME]
password = self.config_entry.data[CONF_PASSWORD]
@@ -69,23 +68,8 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[dict[str, DeviceSummary]]):
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
current = {device.id: device for device in devices}
if self.data is not None:
stale_ids = set(self.data) - set(current)
if stale_ids:
device_registry = dr.async_get(self.hass)
for device_id in stale_ids:
if device := device_registry.async_get_device(
identifiers={(DOMAIN, device_id)}
):
device_registry.async_update_device(
device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
return current
else:
return devices
class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]):

View File

@@ -1,34 +0,0 @@
"""Diagnostics support for Fresh-r."""
from __future__ import annotations
import dataclasses
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .coordinator import FreshrConfigEntry
TO_REDACT = {CONF_PASSWORD}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: FreshrConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
runtime_data = entry.runtime_data
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
"devices": [
dataclasses.asdict(device) for device in runtime_data.devices.data.values()
],
"readings": {
device_id: dataclasses.asdict(coordinator.data)
if coordinator.data is not None
else None
for device_id, coordinator in runtime_data.readings.items()
},
}

View File

@@ -41,13 +41,11 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Integration connects to a cloud service; no local network discovery is possible.
discovery:
status: exempt
comment: No local network discovery of devices is possible (no zeroconf, mdns or other discovery mechanisms).
discovery: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
@@ -55,18 +53,18 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow.
stale-devices: done
stale-devices: todo
# Platinum
async-dependency: done

View File

@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -112,43 +112,26 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Fresh-r sensors from a config entry."""
coordinator = config_entry.runtime_data.devices
known_devices: set[str] = set()
@callback
def _check_devices() -> None:
current = set(coordinator.data)
removed_ids = known_devices - current
if removed_ids:
known_devices.difference_update(removed_ids)
new_ids = current - known_devices
if not new_ids:
return
known_devices.update(new_ids)
entities: list[FreshrSensor] = []
for device_id in new_ids:
device = coordinator.data[device_id]
descriptions = SENSOR_TYPES.get(
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R]
entities: list[FreshrSensor] = []
for device in config_entry.runtime_data.devices.data:
descriptions = SENSOR_TYPES.get(
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R]
)
device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"),
serial_number=device.id,
manufacturer="Fresh-r",
)
entities.extend(
FreshrSensor(
config_entry.runtime_data.readings[device.id],
description,
device_info,
)
device_info = DeviceInfo(
identifiers={(DOMAIN, device_id)},
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"),
serial_number=device_id,
manufacturer="Fresh-r",
)
entities.extend(
FreshrSensor(
config_entry.runtime_data.readings[device_id],
description,
device_info,
)
for description in descriptions
)
async_add_entities(entities)
_check_devices()
config_entry.async_on_unload(coordinator.async_add_listener(_check_devices))
for description in descriptions
)
async_add_entities(entities)
class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity):

View File

@@ -2,8 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -20,15 +19,6 @@
},
"description": "Re-enter the password for your Fresh-r account `{username}`."
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::freshr::config::step::user::data_description::password%]"
},
"description": "Update the password for your Fresh-r account `{username}`."
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",

View File

@@ -34,17 +34,23 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery-update-info: todo
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations:
status: exempt
comment: no known limitations, yet
docs-supported-devices: done
docs-supported-functions: done
docs-supported-devices:
status: todo
comment: add the known supported devices
docs-supported-functions:
status: todo
comment: need to be overhauled
docs-troubleshooting: done
docs-use-cases: done
docs-use-cases:
status: todo
comment: need to be overhauled
dynamic-devices: done
entity-category: done
entity-device-class: done

View File

@@ -97,7 +97,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
super().__init__(coordinator, ain)
@callback
def _async_write_ha_state(self) -> None:
def async_write_ha_state(self) -> None:
"""Write the state to the HASS state machine."""
if self.data.holiday_active:
self._attr_supported_features = ClimateEntityFeature.PRESET_MODE
@@ -109,7 +109,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
self._attr_supported_features = SUPPORTED_FEATURES
self._attr_hvac_modes = HVAC_MODES
self._attr_preset_modes = PRESET_MODES
return super()._async_write_ha_state()
return super().async_write_ha_state()
@property
def current_temperature(self) -> float:

View File

@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.3.0"]
"requirements": ["gardena-bluetooth==2.1.0"]
}

View File

@@ -1,4 +1,4 @@
"""The Glances integration."""
"""The Glances component."""
import logging
from typing import Any

View File

@@ -1,4 +1,4 @@
"""Constants for Glances integration."""
"""Constants for Glances component."""
from datetime import timedelta
import sys

View File

@@ -1,4 +1,4 @@
"""Support gathering system information of hosts which are running Glances."""
"""Support gathering system information of hosts which are running glances."""
from __future__ import annotations

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["goodwe"],
"requirements": ["goodwe==0.4.10"]
"requirements": ["goodwe==0.4.8"]
}

View File

@@ -24,9 +24,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from homeassistant.helpers.entity import generate_entity_id
from .api import ApiAuthImpl, get_feature_access
@@ -91,17 +88,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo
_LOGGER.error("Configuration error in %s: %s", YAML_DEVICES, str(err))
return False
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
# Force a token refresh to fix a bug where tokens were persisted with
# expires_in (relative time delta) and expires_at (absolute time) swapped.

View File

@@ -57,11 +57,6 @@
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"options": {
"step": {
"init": {

View File

@@ -2,19 +2,14 @@
from __future__ import annotations
from aiohttp import ClientError
import aiohttp
from gassist_text import TextAssistant
from google.oauth2.credentials import Credentials
from homeassistant.components import conversation
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, discovery, intent
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
@@ -63,11 +58,13 @@ async def async_setup_entry(
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except OAuth2TokenRequestReauthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="reauth_required"
) from err
except (OAuth2TokenRequestError, ClientError) as err:
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="reauth_required"
) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
mem_storage = InMemoryStorage(hass)

View File

@@ -8,6 +8,7 @@ import logging
from typing import Any
import uuid
import aiohttp
from aiohttp import web
from gassist_text import TextAssistant
from google.oauth2.credentials import Credentials
@@ -25,11 +26,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
HomeAssistantError,
OAuth2TokenRequestReauthError,
ServiceValidationError,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.event import async_call_later
@@ -82,8 +79,9 @@ async def async_send_text_commands(
session = entry.runtime_data.session
try:
await session.async_ensure_token_valid()
except OAuth2TokenRequestReauthError:
entry.async_start_reauth(hass)
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
entry.async_start_reauth(hass)
raise
credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]

View File

@@ -2,7 +2,8 @@
from functools import partial
from aiohttp.client_exceptions import ClientError
from aiohttp.client_exceptions import ClientError, ClientResponseError
from google.auth.exceptions import RefreshError
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
@@ -13,8 +14,6 @@ from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.helpers import config_entry_oauth2_flow
@@ -38,26 +37,24 @@ class AsyncConfigEntryAuth:
async def check_and_refresh_token(self) -> str:
"""Check the token."""
setup_in_progress = (
self.oauth_session.config_entry.state is ConfigEntryState.SETUP_IN_PROGRESS
)
try:
await self.oauth_session.async_ensure_token_valid()
except OAuth2TokenRequestReauthError as ex:
if setup_in_progress:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
) from ex
self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass)
raise
except OAuth2TokenRequestError as ex:
if setup_in_progress:
raise ConfigEntryNotReady from ex
raise
except ClientError as ex:
if setup_in_progress:
except (RefreshError, ClientResponseError, ClientError) as ex:
if (
self.oauth_session.config_entry.state
is ConfigEntryState.SETUP_IN_PROGRESS
):
if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
) from ex
raise ConfigEntryNotReady from ex
if isinstance(ex, RefreshError) or (
hasattr(ex, "status") and ex.status == 400
):
self.oauth_session.config_entry.async_start_reauth(
self.oauth_session.hass
)
raise HomeAssistantError(ex) from ex
return self.access_token

View File

@@ -33,18 +33,11 @@ async def async_setup_entry(
hass: HomeAssistant, entry: GooglePhotosConfigEntry
) -> bool:
"""Set up Google Photos from a config entry."""
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
except config_entry_oauth2_flow.ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
)
web_session = async_get_clientsession(hass)
oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
auth = api.AsyncConfigEntryAuth(web_session, oauth_session)

View File

@@ -68,9 +68,6 @@
"no_access_to_path": {
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"upload_error": {
"message": "Failed to upload content: {message}"
}

View File

@@ -3,12 +3,14 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from dataclasses import replace
from datetime import datetime
import logging
import os
import re
import struct
from typing import Any, cast
from typing import Any, NamedTuple, cast
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import (
@@ -39,23 +41,35 @@ from homeassistant.components.http import (
)
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_NAME,
EVENT_CORE_CONFIG_UPDATE,
HASSIO_USER_NAME,
SERVER_PORT,
Platform,
)
from homeassistant.core import Event, HassJob, HomeAssistant, callback
from homeassistant.core import (
Event,
HassJob,
HomeAssistant,
ServiceCall,
async_get_hass_or_none,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
discovery_flow,
issue_registry as ir,
selector,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.dt import now
# config_flow, diagnostics, system_health, and entity platforms are imported to
# ensure other dependencies that wait for hassio are not waiting
@@ -78,7 +92,19 @@ from .auth import async_setup_auth_view
from .config import HassioConfig
from .const import (
ADDONS_COORDINATOR,
ATTR_ADDON,
ATTR_ADDONS,
ATTR_APP,
ATTR_APPS,
ATTR_COMPRESSED,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_REPOSITORIES,
ATTR_SLUG,
DATA_ADDONS_LIST,
DATA_COMPONENT,
DATA_CONFIG_STORE,
@@ -92,6 +118,7 @@ from .const import (
DATA_SUPERVISOR_INFO,
DOMAIN,
HASSIO_UPDATE_INTERVAL,
SupervisorEntityModel,
)
from .coordinator import (
HassioDataUpdateCoordinator,
@@ -109,11 +136,15 @@ from .coordinator import (
get_supervisor_stats,
)
from .discovery import async_setup_discovery_view
from .handler import HassIO, async_update_diagnostics, get_supervisor_client
from .handler import (
HassIO,
HassioAPIError,
async_update_diagnostics,
get_supervisor_client,
)
from .http import HassIOView
from .ingress import async_setup_ingress_view
from .issues import SupervisorIssues
from .services import async_setup_services
from .websocket_api import async_load_websocket_api
# Expose the future safe name now so integrations can use it
@@ -159,6 +190,23 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop"
SERVICE_ADDON_RESTART = "addon_restart"
SERVICE_ADDON_STDIN = "addon_stdin"
SERVICE_APP_START = "app_start"
SERVICE_APP_STOP = "app_stop"
SERVICE_APP_RESTART = "app_restart"
SERVICE_APP_STDIN = "app_stdin"
SERVICE_HOST_SHUTDOWN = "host_shutdown"
SERVICE_HOST_REBOOT = "host_reboot"
SERVICE_BACKUP_FULL = "backup_full"
SERVICE_BACKUP_PARTIAL = "backup_partial"
SERVICE_RESTORE_FULL = "restore_full"
SERVICE_RESTORE_PARTIAL = "restore_partial"
SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
DEPRECATION_URL = (
"https://www.home-assistant.io/blog/2025/05/22/"
@@ -166,11 +214,148 @@ DEPRECATION_URL = (
)
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
value = VALID_ADDON_SLUG(value)
hass = async_get_hass_or_none()
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
raise vol.Invalid("Not a valid app slug")
return value
SCHEMA_NO_DATA = vol.Schema({})
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_BACKUP_FULL = vol.Schema(
{
vol.Optional(
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
): cv.string,
vol.Optional(ATTR_PASSWORD): cv.string,
vol.Optional(ATTR_COMPRESSED): cv.boolean,
vol.Optional(ATTR_LOCATION): vol.All(
cv.string, lambda v: None if v == "/backup" else v
),
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
}
)
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
}
)
SCHEMA_RESTORE_FULL = vol.Schema(
{
vol.Required(ATTR_SLUG): cv.slug,
vol.Optional(ATTR_PASSWORD): cv.string,
}
)
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
}
)
SCHEMA_MOUNT_RELOAD = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
selector.DeviceSelectorConfig(
filter=selector.DeviceFilterSelectorConfig(
integration=DOMAIN,
model=SupervisorEntityModel.MOUNT,
)
)
)
}
)
def _is_32_bit() -> bool:
size = struct.calcsize("P")
return size * 8 == 32
class APIEndpointSettings(NamedTuple):
"""Settings for API endpoint."""
command: str
schema: vol.Schema
timeout: int | None = 60
pass_data: bool = False
MAP_SERVICE_API = {
# Legacy addon services
SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON),
SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON),
SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON),
SERVICE_ADDON_STDIN: APIEndpointSettings(
"/addons/{addon}/stdin", SCHEMA_ADDON_STDIN
),
# New app services
SERVICE_APP_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_APP),
SERVICE_APP_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_APP),
SERVICE_APP_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_APP),
SERVICE_APP_STDIN: APIEndpointSettings("/addons/{addon}/stdin", SCHEMA_APP_STDIN),
SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA),
SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA),
SERVICE_BACKUP_FULL: APIEndpointSettings(
"/backups/new/full",
SCHEMA_BACKUP_FULL,
None,
True,
),
SERVICE_BACKUP_PARTIAL: APIEndpointSettings(
"/backups/new/partial",
SCHEMA_BACKUP_PARTIAL,
None,
True,
),
SERVICE_RESTORE_FULL: APIEndpointSettings(
"/backups/{slug}/restore/full",
SCHEMA_RESTORE_FULL,
None,
True,
),
SERVICE_RESTORE_PARTIAL: APIEndpointSettings(
"/backups/{slug}/restore/partial",
SCHEMA_RESTORE_PARTIAL,
None,
True,
),
}
HARDWARE_INTEGRATIONS = {
"green": "homeassistant_green",
"odroid-c2": "hardkernel",
@@ -212,7 +397,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
host = os.environ["SUPERVISOR"]
websession = async_get_clientsession(hass)
hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host)
hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host)
supervisor_client = get_supervisor_client(hass)
try:
@@ -325,8 +510,74 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
issues_task = hass.async_create_task(issues.setup(), eager_start=True)
# Register services
async_setup_services(hass, supervisor_client)
async def async_service_handler(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
api_endpoint = MAP_SERVICE_API[service.service]
data = service.data.copy()
addon = data.pop(ATTR_APP, None) or data.pop(ATTR_ADDON, None)
slug = data.pop(ATTR_SLUG, None)
if addons := data.pop(ATTR_APPS, None) or data.pop(ATTR_ADDONS, None):
data[ATTR_ADDONS] = addons
payload = None
# Pass data to Hass.io API
if service.service in (SERVICE_ADDON_STDIN, SERVICE_APP_STDIN):
payload = data[ATTR_INPUT]
elif api_endpoint.pass_data:
payload = data
# Call API
# The exceptions are logged properly in hassio.send_command
with suppress(HassioAPIError):
await hassio.send_command(
api_endpoint.command.format(addon=addon, slug=slug),
payload=payload,
timeout=api_endpoint.timeout,
)
for service, settings in MAP_SERVICE_API.items():
hass.services.async_register(
DOMAIN, service, async_service_handler, schema=settings.schema
)
dev_reg = dr.async_get(hass)
async def async_mount_reload(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
coordinator: HassioDataUpdateCoordinator | None = None
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_unknown_device_id",
)
if (
device.name is None
or device.model != SupervisorEntityModel.MOUNT
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
or coordinator.entry_id not in device.config_entries
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_invalid_device",
)
try:
await supervisor_client.mounts.reload_mount(device.name)
except SupervisorError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mount_reload_error",
translation_placeholders={"name": device.name, "error": str(error)},
) from error
hass.services.async_register(
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
)
async def update_info_data(_: datetime | None = None) -> None:
"""Update last available supervisor information."""

View File

@@ -26,7 +26,7 @@ from aiohasupervisor.models import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from .handler import get_supervisor_client
from .handler import HassioAPIError, get_supervisor_client
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]]
type _ReturnFuncType[_T, **_P, _R] = Callable[
@@ -36,15 +36,18 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[
def api_error[_AddonManagerT: AddonManager, **_P, _R](
error_message: str,
*,
expected_error_type: type[HassioAPIError | SupervisorError] | None = None,
) -> Callable[
[_FuncType[_AddonManagerT, _P, _R]], _ReturnFuncType[_AddonManagerT, _P, _R]
]:
"""Handle SupervisorError and raise a specific AddonError."""
"""Handle HassioAPIError and raise a specific AddonError."""
error_type = expected_error_type or (HassioAPIError, SupervisorError)
def handle_supervisor_error(
def handle_hassio_api_error(
func: _FuncType[_AddonManagerT, _P, _R],
) -> _ReturnFuncType[_AddonManagerT, _P, _R]:
"""Handle a SupervisorError."""
"""Handle a HassioAPIError."""
@wraps(func)
async def wrapper(
@@ -53,7 +56,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
"""Wrap an add-on manager method."""
try:
return_value = await func(self, *args, **kwargs)
except SupervisorError as err:
except error_type as err:
raise AddonError(
f"{error_message.format(addon_name=self.addon_name)}: {err}"
) from err
@@ -62,7 +65,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
return wrapper
return handle_supervisor_error
return handle_hassio_api_error
@dataclass
@@ -125,7 +128,10 @@ class AddonManager:
)
)
@api_error("Failed to get the {addon_name} app discovery info")
@api_error(
"Failed to get the {addon_name} app discovery info",
expected_error_type=SupervisorError,
)
async def async_get_addon_discovery_info(self) -> dict:
"""Return add-on discovery info."""
discovery_info = next(
@@ -142,7 +148,10 @@ class AddonManager:
return discovery_info.config
@api_error("Failed to get the {addon_name} app info")
@api_error(
"Failed to get the {addon_name} app info",
expected_error_type=SupervisorError,
)
async def async_get_addon_info(self) -> AddonInfo:
"""Return and cache manager add-on info."""
addon_store_info = await self._supervisor_client.store.addon_info(
@@ -190,14 +199,19 @@ class AddonManager:
version=addon_info.version,
)
@api_error("Failed to set the {addon_name} app options")
@api_error(
"Failed to set the {addon_name} app options",
expected_error_type=SupervisorError,
)
async def async_set_addon_options(self, config: dict) -> None:
"""Set manager add-on options."""
await self._supervisor_client.addons.set_addon_options(
self.addon_slug, AddonsOptions(config=config)
)
@api_error("Failed to install the {addon_name} app")
@api_error(
"Failed to install the {addon_name} app", expected_error_type=SupervisorError
)
async def async_install_addon(self) -> None:
"""Install the managed add-on."""
try:
@@ -207,7 +221,10 @@ class AddonManager:
f"{self.addon_name} app is not available: {err!s}"
) from None
@api_error("Failed to uninstall the {addon_name} app")
@api_error(
"Failed to uninstall the {addon_name} app",
expected_error_type=SupervisorError,
)
async def async_uninstall_addon(self) -> None:
"""Uninstall the managed add-on."""
await self._supervisor_client.addons.uninstall_addon(self.addon_slug)
@@ -242,22 +259,31 @@ class AddonManager:
self.addon_slug, StoreAddonUpdate(backup=False)
)
@api_error("Failed to start the {addon_name} app")
@api_error(
"Failed to start the {addon_name} app", expected_error_type=SupervisorError
)
async def async_start_addon(self) -> None:
"""Start the managed add-on."""
await self._supervisor_client.addons.start_addon(self.addon_slug)
@api_error("Failed to restart the {addon_name} app")
@api_error(
"Failed to restart the {addon_name} app", expected_error_type=SupervisorError
)
async def async_restart_addon(self) -> None:
"""Restart the managed add-on."""
await self._supervisor_client.addons.restart_addon(self.addon_slug)
@api_error("Failed to stop the {addon_name} app")
@api_error(
"Failed to stop the {addon_name} app", expected_error_type=SupervisorError
)
async def async_stop_addon(self) -> None:
"""Stop the managed add-on."""
await self._supervisor_client.addons.stop_addon(self.addon_slug)
@api_error("Failed to create a backup of the {addon_name} app")
@api_error(
"Failed to create a backup of the {addon_name} app",
expected_error_type=SupervisorError,
)
async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None:
"""Create a partial backup of the managed add-on."""
if addon_info:

View File

@@ -1,439 +0,0 @@
"""Set up Supervisor services."""
from collections.abc import Awaitable, Callable
import json
import re
from typing import Any
from aiohasupervisor import SupervisorClient, SupervisorError
from aiohasupervisor.models import (
FullBackupOptions,
FullRestoreOptions,
PartialBackupOptions,
PartialRestoreOptions,
)
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
async_get_hass_or_none,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
selector,
)
from homeassistant.util.dt import now
from .const import (
ADDONS_COORDINATOR,
ATTR_ADDON,
ATTR_ADDONS,
ATTR_APP,
ATTR_APPS,
ATTR_COMPRESSED,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_SLUG,
DOMAIN,
SupervisorEntityModel,
)
from .coordinator import HassioDataUpdateCoordinator, get_addons_info
SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop"
SERVICE_ADDON_RESTART = "addon_restart"
SERVICE_ADDON_STDIN = "addon_stdin"
SERVICE_APP_START = "app_start"
SERVICE_APP_STOP = "app_stop"
SERVICE_APP_RESTART = "app_restart"
SERVICE_APP_STDIN = "app_stdin"
SERVICE_HOST_SHUTDOWN = "host_shutdown"
SERVICE_HOST_REBOOT = "host_reboot"
SERVICE_BACKUP_FULL = "backup_full"
SERVICE_BACKUP_PARTIAL = "backup_partial"
SERVICE_RESTORE_FULL = "restore_full"
SERVICE_RESTORE_PARTIAL = "restore_partial"
SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
value = VALID_ADDON_SLUG(value)
hass = async_get_hass_or_none()
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
raise vol.Invalid("Not a valid app slug")
return value
SCHEMA_NO_DATA = vol.Schema({})
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_BACKUP_FULL = vol.Schema(
{
vol.Optional(
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
): cv.string,
vol.Optional(ATTR_PASSWORD): cv.string,
vol.Optional(ATTR_COMPRESSED): cv.boolean,
vol.Optional(ATTR_LOCATION): vol.All(
cv.string, lambda v: None if v == "/backup" else v
),
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
}
)
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
}
)
SCHEMA_RESTORE_FULL = vol.Schema(
{
vol.Required(ATTR_SLUG): cv.slug,
vol.Optional(ATTR_PASSWORD): cv.string,
}
)
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
}
)
SCHEMA_MOUNT_RELOAD = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
selector.DeviceSelectorConfig(
filter=selector.DeviceFilterSelectorConfig(
integration=DOMAIN,
model=SupervisorEntityModel.MOUNT,
)
)
)
}
)
@callback
def async_setup_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register the Supervisor services."""
async_register_app_services(hass, supervisor_client)
async_register_host_services(hass, supervisor_client)
async_register_backup_restore_services(hass, supervisor_client)
async_register_network_storage_services(hass, supervisor_client)
@callback
def async_register_app_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register app services."""
simple_app_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
SERVICE_APP_START: ("start", supervisor_client.addons.start_addon),
SERVICE_APP_RESTART: ("restart", supervisor_client.addons.restart_addon),
SERVICE_APP_STOP: ("stop", supervisor_client.addons.stop_addon),
}
async def async_simple_app_service_handler(service: ServiceCall) -> None:
"""Handles app services which only take a slug and have no response."""
action, api_method = simple_app_services[service.service]
app_slug = service.data[ATTR_APP]
try:
await api_method(app_slug)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to {action} app {app_slug}: {err}"
) from err
for service in simple_app_services:
hass.services.async_register(
DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP
)
async def async_app_stdin_service_handler(service: ServiceCall) -> None:
"""Handles app stdin service."""
app_slug = service.data[ATTR_APP]
data: dict | str = service.data[ATTR_INPUT]
# For backwards compatibility the payload here must be valid json
# This is sensible when a dictionary is provided, it must be serialized
# If user provides a string though, we wrap it in quotes before encoding
# This is purely for legacy reasons, Supervisor has no json requirement
# Supervisor just hands the raw request as binary to the container
data = json.dumps(data)
payload = data.encode(encoding="utf-8")
try:
await supervisor_client.addons.write_addon_stdin(app_slug, payload)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to write stdin to app {app_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_APP_STDIN,
async_app_stdin_service_handler,
schema=SCHEMA_APP_STDIN,
)
# LEGACY - Register equivalent addon services for compatibility
simple_addon_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
SERVICE_ADDON_START: ("start", supervisor_client.addons.start_addon),
SERVICE_ADDON_RESTART: ("restart", supervisor_client.addons.restart_addon),
SERVICE_ADDON_STOP: ("stop", supervisor_client.addons.stop_addon),
}
async def async_simple_addon_service_handler(service: ServiceCall) -> None:
"""Handles addon services which only take a slug and have no response."""
action, api_method = simple_addon_services[service.service]
addon_slug = service.data[ATTR_ADDON]
try:
await api_method(addon_slug)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to {action} app {addon_slug}: {err}"
) from err
for service in simple_addon_services:
hass.services.async_register(
DOMAIN, service, async_simple_addon_service_handler, schema=SCHEMA_ADDON
)
async def async_addon_stdin_service_handler(service: ServiceCall) -> None:
"""Handles addon stdin service."""
addon_slug = service.data[ATTR_ADDON]
data: dict | str = service.data[ATTR_INPUT]
# See explanation for why we make strings into json in async_app_stdin_service_handler
data = json.dumps(data)
payload = data.encode(encoding="utf-8")
try:
await supervisor_client.addons.write_addon_stdin(addon_slug, payload)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to write stdin to app {addon_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_ADDON_STDIN,
async_addon_stdin_service_handler,
schema=SCHEMA_ADDON_STDIN,
)
@callback
def async_register_host_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register host services."""
simple_host_services: dict[str, tuple[str, Callable[[], Awaitable[None]]]] = {
SERVICE_HOST_REBOOT: ("reboot", supervisor_client.host.reboot),
SERVICE_HOST_SHUTDOWN: ("shutdown", supervisor_client.host.shutdown),
}
async def async_simple_host_service_handler(service: ServiceCall) -> None:
"""Handler for host services that take no input and return no response."""
action, api_method = simple_host_services[service.service]
try:
await api_method()
except SupervisorError as err:
raise HomeAssistantError(f"Failed to {action} the host: {err}") from err
for service in simple_host_services:
hass.services.async_register(
DOMAIN, service, async_simple_host_service_handler, schema=SCHEMA_NO_DATA
)
@callback
def async_register_backup_restore_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register backup and restore services."""
async def async_full_backup_service_handler(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create full backup service. Returns the new backup's ID."""
options = FullBackupOptions(**service.data)
try:
backup = await supervisor_client.backups.full_backup(options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to create full backup {options.name}: {err}"
) from err
return {"backup": backup.slug}
hass.services.async_register(
DOMAIN,
SERVICE_BACKUP_FULL,
async_full_backup_service_handler,
schema=SCHEMA_BACKUP_FULL,
supports_response=SupportsResponse.OPTIONAL,
)
async def async_partial_backup_service_handler(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create partial backup service. Returns the new backup's ID."""
data = service.data.copy()
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
options = PartialBackupOptions(**data)
try:
backup = await supervisor_client.backups.partial_backup(options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to create partial backup {options.name}: {err}"
) from err
return {"backup": backup.slug}
hass.services.async_register(
DOMAIN,
SERVICE_BACKUP_PARTIAL,
async_partial_backup_service_handler,
schema=SCHEMA_BACKUP_PARTIAL,
supports_response=SupportsResponse.OPTIONAL,
)
async def async_full_restore_service_handler(service: ServiceCall) -> None:
"""Handler for full restore service."""
backup_slug = service.data[ATTR_SLUG]
options: FullRestoreOptions | None = None
if ATTR_PASSWORD in service.data:
options = FullRestoreOptions(password=service.data[ATTR_PASSWORD])
try:
await supervisor_client.backups.full_restore(backup_slug, options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to full restore from backup {backup_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_RESTORE_FULL,
async_full_restore_service_handler,
schema=SCHEMA_RESTORE_FULL,
)
async def async_partial_restore_service_handler(service: ServiceCall) -> None:
"""Handler for partial restore service."""
data = service.data.copy()
backup_slug = data.pop(ATTR_SLUG)
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
options = PartialRestoreOptions(**data)
try:
await supervisor_client.backups.partial_restore(backup_slug, options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to partial restore from backup {backup_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_RESTORE_PARTIAL,
async_partial_restore_service_handler,
schema=SCHEMA_RESTORE_PARTIAL,
)
@callback
def async_register_network_storage_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register network storage (or mount) services."""
dev_reg = dr.async_get(hass)
async def async_mount_reload(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
coordinator: HassioDataUpdateCoordinator | None = None
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_unknown_device_id",
)
if (
device.name is None
or device.model != SupervisorEntityModel.MOUNT
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
or coordinator.entry_id not in device.config_entries
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_invalid_device",
)
try:
await supervisor_client.mounts.reload_mount(device.name)
except SupervisorError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mount_reload_error",
translation_placeholders={"name": device.name, "error": str(error)},
) from error
hass.services.async_register(
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
)

View File

@@ -18,6 +18,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from . import HassioAPIError
from .config import HassioUpdateParametersDict
from .const import (
ATTR_DATA,
@@ -39,7 +40,6 @@ from .const import (
WS_TYPE_SUBSCRIBE,
)
from .coordinator import get_addons_list
from .handler import HassioAPIError
from .update_helper import update_addon, update_core
SCHEMA_WEBSOCKET_EVENT = vol.Schema(

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