Compare commits

..

104 Commits

Author SHA1 Message Date
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
261 changed files with 2452 additions and 7550 deletions

View File

@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.02.0"
BASE_IMAGE_VERSION: "2026.01.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
@@ -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@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
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@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
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

@@ -1 +1 @@
3.14.3
3.14.2

4
CODEOWNERS generated
View File

@@ -1228,8 +1228,8 @@ 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

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

@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
from python_homeassistant_analytics import (
Environment,
HomeassistantAnalyticsClient,
HomeassistantAnalyticsConnectionError,
)
@@ -38,7 +39,7 @@ async def async_setup_entry(
client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass))
try:
integrations = await client.get_integrations()
integrations = await client.get_integrations(Environment.NEXT)
except HomeassistantAnalyticsConnectionError as ex:
raise ConfigEntryNotReady("Could not fetch integration list") from ex

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

@@ -401,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
@@ -447,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
@@ -611,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()
@@ -672,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] = [
@@ -764,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"] = [
@@ -869,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:
@@ -901,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

@@ -59,7 +59,10 @@ 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:
status: todo
@@ -85,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

@@ -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

@@ -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

@@ -122,7 +122,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"battery",
"calendar",
"climate",
"cover",
"device_tracker",
@@ -148,7 +147,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"switch",
"temperature",
"text",
"timer",
"vacuum",
"valve",
"water_heater",
@@ -193,6 +191,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"todo",
"update",
"vacuum",
"valve",
"water_heater",
"window",
}

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

@@ -1,16 +0,0 @@
"""Provides conditions for calendars."""
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_event_active": make_entity_state_condition(DOMAIN, STATE_ON),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the calendar conditions."""
return CONDITIONS

View File

@@ -1,14 +0,0 @@
is_event_active:
target:
entity:
- domain: calendar
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any

View File

@@ -1,9 +1,4 @@
{
"conditions": {
"is_event_active": {
"condition": "mdi:calendar-check"
}
},
"entity_component": {
"_": {
"default": "mdi:calendar",

View File

@@ -1,20 +1,4 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted calendars.",
"condition_behavior_name": "Behavior"
},
"conditions": {
"is_event_active": {
"description": "Tests if one or more calendars have an active event.",
"fields": {
"behavior": {
"description": "[%key:component::calendar::common::condition_behavior_description%]",
"name": "[%key:component::calendar::common::condition_behavior_name%]"
}
},
"name": "Calendar event is active"
}
},
"entity_component": {
"_": {
"name": "[%key:component::calendar::title%]",
@@ -62,12 +46,6 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_offset_type": {
"options": {
"after": "After",

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -45,6 +45,13 @@ SUPPORT_FLAGS_HEATER = (
)
def _operation_mode_to_ha(mode: WaterHeaterOperationMode | None) -> str:
"""Translate an EcoNet operation mode to a Home Assistant state."""
if mode in (None, WaterHeaterOperationMode.VACATION):
return STATE_OFF
return ECONET_STATE_TO_HA[mode]
async def async_setup_entry(
hass: HomeAssistant,
entry: EconetConfigEntry,
@@ -80,26 +87,22 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity):
@property
def current_operation(self) -> str:
"""Return current operation."""
econet_mode = self.water_heater.mode
_current_op = STATE_OFF
if econet_mode is not None:
_current_op = ECONET_STATE_TO_HA[econet_mode]
return _current_op
return _operation_mode_to_ha(self.water_heater.mode)
@property
def operation_list(self) -> list[str]:
"""List of available operation modes."""
econet_modes = self.water_heater.modes
operation_modes = set()
for mode in econet_modes:
if (
mode is not WaterHeaterOperationMode.UNKNOWN
and mode is not WaterHeaterOperationMode.VACATION
):
ha_mode = ECONET_STATE_TO_HA[mode]
operation_modes.add(ha_mode)
return list(operation_modes)
return list(
dict.fromkeys(
ECONET_STATE_TO_HA[mode]
for mode in self.water_heater.modes
if mode
not in (
WaterHeaterOperationMode.UNKNOWN,
WaterHeaterOperationMode.VACATION,
)
)
)
@property
def supported_features(self) -> WaterHeaterEntityFeature:

View File

@@ -10,6 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from homeassistant.helpers.httpx_client import get_async_client
from .const import DOMAIN, UPNP_AVAILABLE
@@ -40,6 +41,7 @@ class FingConfigFlow(ConfigFlow, domain=DOMAIN):
ip=user_input[CONF_IP_ADDRESS],
port=int(user_input[CONF_PORT]),
key=user_input[CONF_API_KEY],
client=get_async_client(self.hass),
)
try:

View File

@@ -11,6 +11,7 @@ import httpx
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPNP_AVAILABLE
@@ -38,6 +39,7 @@ class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]):
ip=config_entry.data[CONF_IP_ADDRESS],
port=int(config_entry.data[CONF_PORT]),
key=config_entry.data[CONF_API_KEY],
client=get_async_client(hass),
)
self._upnp_available = config_entry.data[UPNP_AVAILABLE]
update_interval = timedelta(seconds=30)

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["fing_agent_api==1.0.3"]
"requirements": ["fing_agent_api==1.1.0"]
}

View File

@@ -68,5 +68,5 @@ rules:
# Platinum
async-dependency: todo
inject-websession: todo
inject-websession: done
strict-typing: todo

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

@@ -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

@@ -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

@@ -45,9 +45,7 @@ rules:
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,7 +53,7 @@ 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
@@ -66,7 +64,7 @@ rules:
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

@@ -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

@@ -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

@@ -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

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState
from homematicip.base.enums import SmokeDetectorAlarmType, WindowState
from homematicip.base.functionalChannels import MultiModeInputChannel
from homematicip.device import (
AccelerationSensor,
@@ -74,30 +74,6 @@ SAM_DEVICE_ATTRIBUTES = {
}
def _is_full_flush_lock_controller(device: object) -> bool:
"""Return whether the device is an HmIP-FLC."""
return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr(
device, "functionalChannels"
)
def _get_channel_by_role(
device: object,
functional_channel_type: str,
channel_role: str,
) -> object | None:
"""Return the matching functional channel for the device."""
for channel in getattr(device, "functionalChannels", []):
channel_type = getattr(channel, "functionalChannelType", None)
channel_type_name = getattr(channel_type, "name", channel_type)
if channel_type_name != functional_channel_type:
continue
if getattr(channel, "channelRole", None) != channel_role:
continue
return channel
return None
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomematicIPConfigEntry,
@@ -146,9 +122,6 @@ async def async_setup_entry(
entities.append(
HomematicipPluggableMainsFailureSurveillanceSensor(hap, device)
)
if _is_full_flush_lock_controller(device):
entities.append(HomematicipFullFlushLockControllerLocked(hap, device))
entities.append(HomematicipFullFlushLockControllerGlassBreak(hap, device))
if isinstance(device, PresenceDetectorIndoor):
entities.append(HomematicipPresenceDetector(hap, device))
if isinstance(device, SmokeDetector):
@@ -325,55 +298,6 @@ class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity):
return self._device.motionDetected
class HomematicipFullFlushLockControllerLocked(
HomematicipGenericEntity, BinarySensorEntity
):
"""Representation of the HomematicIP full flush lock controller lock state."""
_attr_device_class = BinarySensorDeviceClass.LOCK
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the full flush lock controller lock sensor."""
super().__init__(hap, device, post="Locked")
@property
def is_on(self) -> bool:
"""Return true if the controlled lock is locked."""
channel = _get_channel_by_role(
self._device,
"MULTI_MODE_LOCK_INPUT_CHANNEL",
"DOOR_LOCK_SENSOR",
)
if channel is None:
return False
lock_state = getattr(channel, "lockState", None)
return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
class HomematicipFullFlushLockControllerGlassBreak(
HomematicipGenericEntity, BinarySensorEntity
):
"""Representation of the HomematicIP full flush lock controller glass state."""
_attr_device_class = BinarySensorDeviceClass.PROBLEM
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the full flush lock controller glass break sensor."""
super().__init__(hap, device, post="Glass break")
@property
def is_on(self) -> bool:
"""Return true if glass break has been detected."""
channel = _get_channel_by_role(
self._device,
"MULTI_MODE_LOCK_INPUT_CHANNEL",
"DOOR_LOCK_SENSOR",
)
if channel is None:
return False
return bool(getattr(channel, "glassBroken", False))
class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP presence detector."""

View File

@@ -12,13 +12,6 @@ from .entity import HomematicipGenericEntity
from .hap import HomematicIPConfigEntry, HomematicipHAP
def _is_full_flush_lock_controller(device: object) -> bool:
"""Return whether the device is an HmIP-FLC."""
return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr(
device, "send_start_impulse_async"
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomematicIPConfigEntry,
@@ -27,17 +20,11 @@ async def async_setup_entry(
"""Set up the HomematicIP button from a config entry."""
hap = config_entry.runtime_data
entities: list[ButtonEntity] = [
async_add_entities(
HomematicipGarageDoorControllerButton(hap, device)
for device in hap.home.devices
if isinstance(device, WallMountedGarageDoorController)
]
entities.extend(
HomematicipFullFlushLockControllerButton(hap, device)
for device in hap.home.devices
if _is_full_flush_lock_controller(device)
)
async_add_entities(entities)
class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEntity):
@@ -51,16 +38,3 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti
async def async_press(self) -> None:
"""Handle the button press."""
await self._device.send_start_impulse_async()
class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonEntity):
"""Representation of the HomematicIP full flush lock controller opener."""
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the full flush lock controller opener button."""
super().__init__(hap, device, post="Door opener")
self._attr_icon = "mdi:door-open"
async def async_press(self) -> None:
"""Handle the button press."""
await self._device.send_start_impulse_async()

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["python_qube_heatpump"],
"quality_scale": "bronze",
"requirements": ["python-qube-heatpump==1.8.0"]
"requirements": ["python-qube-heatpump==1.7.0"]
}

View File

@@ -9,7 +9,7 @@ from .const import DOMAIN
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.EVENT, Platform.NOTIFY]
PLATFORMS = [Platform.NOTIFY]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View File

@@ -7,9 +7,3 @@ SERVICE_DISMISS = "dismiss"
ATTR_VAPID_PUB_KEY = "vapid_pub_key"
ATTR_VAPID_PRV_KEY = "vapid_prv_key"
ATTR_VAPID_EMAIL = "vapid_email"
REGISTRATIONS_FILE = "html5_push_registrations.conf"
ATTR_ACTION = "action"
ATTR_DATA = "data"
ATTR_TAG = "tag"

View File

@@ -1,73 +0,0 @@
"""Base entities for HTML5 integration."""
from __future__ import annotations
from typing import NotRequired, TypedDict
from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
class Keys(TypedDict):
"""Types for keys."""
p256dh: str
auth: str
class Subscription(TypedDict):
"""Types for subscription."""
endpoint: str
expirationTime: int | None
keys: Keys
class Registration(TypedDict):
"""Types for registration."""
subscription: Subscription
browser: str
name: NotRequired[str]
class HTML5Entity(Entity):
"""Base entity for HTML5 integration."""
_attr_has_entity_name = True
_attr_name = None
_key: str
def __init__(
self,
config_entry: ConfigEntry,
target: str,
registrations: dict[str, Registration],
session: ClientSession,
json_path: str,
) -> None:
"""Initialize the entity."""
self.config_entry = config_entry
self.target = target
self.registrations = registrations
self.registration = registrations[target]
self.session = session
self.json_path = json_path
self._attr_unique_id = f"{config_entry.entry_id}_{target}_{self._key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name=target,
model=self.registration["browser"].capitalize(),
identifiers={(DOMAIN, f"{config_entry.entry_id}_{target}")},
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.target in self.registrations

View File

@@ -1,67 +0,0 @@
"""Event platform for HTML5 integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.event import EventEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_ACTION, ATTR_DATA, ATTR_TAG, DOMAIN, REGISTRATIONS_FILE
from .entity import HTML5Entity
from .notify import _load_config
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the event entity platform."""
json_path = hass.config.path(REGISTRATIONS_FILE)
registrations = await hass.async_add_executor_job(_load_config, json_path)
session = async_get_clientsession(hass)
async_add_entities(
HTML5EventEntity(config_entry, target, registrations, session, json_path)
for target in registrations
)
class HTML5EventEntity(HTML5Entity, EventEntity):
"""Representation of an event entity."""
_key = "event"
_attr_event_types = ["clicked", "received", "closed"]
_attr_translation_key = "event"
@callback
def _async_handle_event(
self, target: str, event_type: str, event_data: dict[str, Any]
) -> None:
"""Handle the event."""
if target == self.target:
self._trigger_event(
event_type,
{
**event_data.get(ATTR_DATA, {}),
ATTR_ACTION: event_data.get(ATTR_ACTION),
ATTR_TAG: event_data.get(ATTR_TAG),
},
)
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register event callback."""
self.async_on_remove(
async_dispatcher_connect(self.hass, DOMAIN, self._async_handle_event)
)

View File

@@ -1,11 +1,4 @@
{
"entity": {
"event": {
"event": {
"default": "mdi:gesture-tap-button"
}
}
},
"services": {
"dismiss": {
"service": "mdi:bell-off"

View File

@@ -8,7 +8,7 @@ from http import HTTPStatus
import json
import logging
import time
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast
from urllib.parse import urlparse
import uuid
@@ -38,7 +38,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.json import save_json
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -46,19 +46,17 @@ from homeassistant.util import ensure_unique_string
from homeassistant.util.json import load_json_object
from .const import (
ATTR_ACTION,
ATTR_TAG,
ATTR_VAPID_EMAIL,
ATTR_VAPID_PRV_KEY,
ATTR_VAPID_PUB_KEY,
DOMAIN,
REGISTRATIONS_FILE,
SERVICE_DISMISS,
)
from .entity import HTML5Entity, Registration
_LOGGER = logging.getLogger(__name__)
REGISTRATIONS_FILE = "html5_push_registrations.conf"
ATTR_SUBSCRIPTION = "subscription"
ATTR_BROWSER = "browser"
@@ -69,6 +67,8 @@ ATTR_AUTH = "auth"
ATTR_P256DH = "p256dh"
ATTR_EXPIRATIONTIME = "expirationTime"
ATTR_TAG = "tag"
ATTR_ACTION = "action"
ATTR_ACTIONS = "actions"
ATTR_TYPE = "type"
ATTR_URL = "url"
@@ -156,6 +156,29 @@ HTML5_SHOWNOTIFICATION_PARAMETERS = (
)
class Keys(TypedDict):
"""Types for keys."""
p256dh: str
auth: str
class Subscription(TypedDict):
"""Types for subscription."""
endpoint: str
expirationTime: int | None
keys: Keys
class Registration(TypedDict):
"""Types for registration."""
subscription: Subscription
browser: str
name: NotRequired[str]
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
@@ -396,15 +419,7 @@ class HTML5PushCallbackView(HomeAssistantView):
)
event_name = f"{NOTIFY_CALLBACK_EVENT}.{event_payload[ATTR_TYPE]}"
hass = request.app[KEY_HASS]
hass.bus.fire(event_name, event_payload)
async_dispatcher_send(
hass,
DOMAIN,
event_payload[ATTR_TARGET],
event_payload[ATTR_TYPE],
event_payload,
)
request.app[KEY_HASS].bus.fire(event_name, event_payload)
return self.json({"status": "ok", "event": event_payload[ATTR_TYPE]})
@@ -598,11 +613,37 @@ async def async_setup_entry(
)
class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
class HTML5NotifyEntity(NotifyEntity):
"""Representation of a notification entity."""
_attr_has_entity_name = True
_attr_name = None
_attr_supported_features = NotifyEntityFeature.TITLE
_key = "device"
def __init__(
self,
config_entry: ConfigEntry,
target: str,
registrations: dict[str, Registration],
session: ClientSession,
json_path: str,
) -> None:
"""Initialize the entity."""
self.config_entry = config_entry
self.target = target
self.registrations = registrations
self.registration = registrations[target]
self.session = session
self.json_path = json_path
self._attr_unique_id = f"{config_entry.entry_id}_{target}_device"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name=target,
model=self.registration["browser"].capitalize(),
identifiers={(DOMAIN, f"{config_entry.entry_id}_{target}")},
)
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to a device."""
@@ -673,3 +714,8 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
translation_key="connection_error",
translation_placeholders={"target": self.target},
) from e
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.target in self.registrations

View File

@@ -20,23 +20,6 @@
}
}
},
"entity": {
"event": {
"event": {
"state_attributes": {
"action": { "name": "Action" },
"event_type": {
"state": {
"clicked": "Clicked",
"closed": "Closed",
"received": "Received"
}
},
"tag": { "name": "Tag" }
}
}
}
},
"exceptions": {
"channel_expired": {
"message": "Notification channel for {target} has expired"
@@ -48,6 +31,12 @@
"message": "Sending notification to {target} failed due to a request error"
}
},
"issues": {
"deprecated_yaml_import_issue": {
"description": "Configuring HTML5 push notification using YAML has been deprecated. An automatic import of your existing configuration was attempted, but it failed.\n\nPlease remove the HTML5 push notification YAML configuration from your configuration.yaml file and reconfigure HTML5 push notification again manually.",
"title": "HTML5 YAML configuration import failed"
}
},
"services": {
"dismiss": {
"description": "Dismisses an HTML5 notification.",

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.3.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"]
}

View File

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

View File

@@ -39,7 +39,7 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
test-coverage: todo
# Gold
devices: done

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/kaleidescape",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["pykaleidescape==1.1.4"],
"requirements": ["pykaleidescape==1.1.3"],
"ssdp": [
{
"deviceType": "schemas-upnp-org:device:Basic:1",

View File

@@ -73,31 +73,45 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
except HTTPError as error:
raise UpdateFailed from error
try:
# Fetch last hour of data
for sensor in self.devices:
# Fetch last hour of data
for sensor in self.devices:
try:
data = await self.api.get_sensor_status(
sensor=sensor,
tz=self.hass.config.time_zone,
)
_LOGGER.debug("Got data: %s", data)
except HTTPError as error:
error_data = error.args[1] if len(error.args) > 1 else None
if (
isinstance(error_data, dict)
and error_data.get("error") == "no_readings"
):
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
) from error
if data_error := data.get("error"):
if data_error == "no_readings":
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
_LOGGER.debug("Error: %s", data_error)
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
)
_LOGGER.debug("Got data: %s", data)
sensor.data = data["data"]["current"]
if data_error := data.get("error"):
if data_error == "no_readings":
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
_LOGGER.debug("Error: %s", data_error)
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
)
except HTTPError as error:
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
) from error
current_data = data.get("data", {}).get("current")
if current_data is None:
sensor.data = None
_LOGGER.debug("No current data payload for %s", sensor.name)
continue
sensor.data = current_data
# Verify that we have permission to read the sensors
for sensor in self.devices:

View File

@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["thinqconnect"],
"requirements": ["thinqconnect==1.0.11"]
"requirements": ["thinqconnect==1.0.9"]
}

View File

@@ -16,10 +16,6 @@ from homeassistant.components.climate import (
ATTR_TARGET_TEMP_LOW,
DEFAULT_MAX_TEMP,
DEFAULT_MIN_TEMP,
PRESET_AWAY,
PRESET_HOME,
PRESET_NONE,
PRESET_SLEEP,
ClimateEntity,
ClimateEntityDescription,
ClimateEntityFeature,
@@ -46,18 +42,6 @@ HVAC_SYSTEM_MODE_MAP = {
HVACMode.FAN_ONLY: 7,
}
# Map of Matter PresetScenarioEnum to HA standard preset constants or custom names
# This ensures presets are translated correctly using HA's translation system.
# kUserDefined scenarios always use device-provided names.
PRESET_SCENARIO_TO_HA_PRESET: dict[int, str] = {
clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied: PRESET_HOME,
clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied: PRESET_AWAY,
clusters.Thermostat.Enums.PresetScenarioEnum.kSleep: PRESET_SLEEP,
clusters.Thermostat.Enums.PresetScenarioEnum.kWake: "wake",
clusters.Thermostat.Enums.PresetScenarioEnum.kVacation: "vacation",
clusters.Thermostat.Enums.PresetScenarioEnum.kGoingToSleep: "going_to_sleep",
}
SINGLE_SETPOINT_DEVICES: set[tuple[int, int]] = {
# Some devices only have a single setpoint while the matter spec
# assumes that you need separate setpoints for heating and cooling.
@@ -175,6 +159,7 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
}
SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum
ControlSequenceEnum = clusters.Thermostat.Enums.ControlSequenceOfOperationEnum
ThermostatFeature = clusters.Thermostat.Bitmaps.Feature
@@ -210,22 +195,10 @@ class MatterClimate(MatterEntity, ClimateEntity):
_attr_temperature_unit: str = UnitOfTemperature.CELSIUS
_attr_hvac_mode: HVACMode = HVACMode.OFF
_matter_presets: list[clusters.Thermostat.Structs.PresetStruct]
_attr_preset_mode: str | None = None
_attr_preset_modes: list[str] | None = None
_feature_map: int | None = None
_platform_translation_key = "thermostat"
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the climate entity."""
# Initialize preset handle mapping as instance attribute before calling super().__init__()
# because MatterEntity.__init__() calls _update_from_device() which needs this attribute
self._matter_presets = []
self._preset_handle_by_name: dict[str, bytes | None] = {}
self._preset_name_by_handle: dict[bytes | None, str] = {}
super().__init__(*args, **kwargs)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE)
@@ -270,34 +243,6 @@ class MatterClimate(MatterEntity, ClimateEntity):
matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
preset_handle = self._preset_handle_by_name[preset_mode]
command = clusters.Thermostat.Commands.SetActivePresetRequest(
presetHandle=preset_handle
)
await self.send_device_command(command)
# Optimistic update is required because Matter devices usually confirm
# preset changes asynchronously via a later attribute subscription.
# Additionally, some devices based on connectedhomeip do not send a
# subscription report for ActivePresetHandle after SetActivePresetRequest
# because thermostat-server-presets.cpp/SetActivePreset() updates the
# value without notifying the reporting engine. Keep this optimistic
# update as a workaround for that SDK bug and for normal report delays.
# Reference: project-chip/connectedhomeip,
# src/app/clusters/thermostat-server/thermostat-server-presets.cpp.
self._attr_preset_mode = preset_mode
self.async_write_ha_state()
# Keep the local ActivePresetHandle in sync until subscription update.
active_preset_path = create_attribute_path_from_attribute(
endpoint_id=self._endpoint.endpoint_id,
attribute=clusters.Thermostat.Attributes.ActivePresetHandle,
)
self._endpoint.set_attribute_value(active_preset_path, preset_handle)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@@ -322,10 +267,10 @@ class MatterClimate(MatterEntity, ClimateEntity):
def _update_from_device(self) -> None:
"""Update from device."""
self._calculate_features()
self._attr_current_temperature = self._get_temperature_in_degrees(
clusters.Thermostat.Attributes.LocalTemperature
)
self._attr_current_humidity = (
int(raw_measured_humidity) / HUMIDITY_SCALING_FACTOR
if (
@@ -337,81 +282,6 @@ class MatterClimate(MatterEntity, ClimateEntity):
else None
)
self._update_presets()
self._update_hvac_mode_and_action()
self._update_target_temperatures()
self._update_temperature_limits()
@callback
def _update_presets(self) -> None:
"""Update preset modes and active preset."""
# Check if the device supports presets feature before attempting to load.
# Use the already computed supported features instead of re-reading
# the FeatureMap attribute to keep a single source of truth and avoid
# casting None when the attribute is temporarily unavailable.
supported_features = self._attr_supported_features or 0
if not (supported_features & ClimateEntityFeature.PRESET_MODE):
# Device does not support presets, skip preset update
self._preset_handle_by_name.clear()
self._preset_name_by_handle.clear()
self._attr_preset_modes = []
self._attr_preset_mode = None
return
self._matter_presets = (
self.get_matter_attribute_value(clusters.Thermostat.Attributes.Presets)
or []
)
# Build preset mapping: use device-provided name if available, else generate unique name
self._preset_handle_by_name.clear()
self._preset_name_by_handle.clear()
if self._matter_presets:
used_names = set()
for i, preset in enumerate(self._matter_presets, start=1):
preset_translation = PRESET_SCENARIO_TO_HA_PRESET.get(
preset.presetScenario
)
if preset_translation:
preset_name = preset_translation.lower()
else:
name = str(preset.name) if preset.name is not None else ""
name = name.strip()
if name:
preset_name = name
else:
# Ensure fallback name is unique
j = i
preset_name = f"Preset{j}"
while preset_name in used_names:
j += 1
preset_name = f"Preset{j}"
used_names.add(preset_name)
preset_handle = (
preset.presetHandle
if isinstance(preset.presetHandle, (bytes, type(None)))
else None
)
self._preset_handle_by_name[preset_name] = preset_handle
self._preset_name_by_handle[preset_handle] = preset_name
# Always include PRESET_NONE to allow users to clear the preset
self._preset_handle_by_name[PRESET_NONE] = None
self._preset_name_by_handle[None] = PRESET_NONE
self._attr_preset_modes = list(self._preset_handle_by_name)
# Update active preset mode
active_preset_handle = self.get_matter_attribute_value(
clusters.Thermostat.Attributes.ActivePresetHandle
)
self._attr_preset_mode = self._preset_name_by_handle.get(
active_preset_handle, PRESET_NONE
)
@callback
def _update_hvac_mode_and_action(self) -> None:
"""Update HVAC mode and action from device."""
if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False:
# special case: the appliance has a dedicated Power switch on the OnOff cluster
# if the mains power is off - treat it as if the HVAC mode is off
@@ -463,10 +333,7 @@ class MatterClimate(MatterEntity, ClimateEntity):
self._attr_hvac_action = HVACAction.FAN
else:
self._attr_hvac_action = HVACAction.OFF
@callback
def _update_target_temperatures(self) -> None:
"""Update target temperature or temperature range."""
# update target temperature high/low
supports_range = (
self._attr_supported_features
& ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
@@ -492,9 +359,6 @@ class MatterClimate(MatterEntity, ClimateEntity):
clusters.Thermostat.Attributes.OccupiedHeatingSetpoint
)
@callback
def _update_temperature_limits(self) -> None:
"""Update min and max temperature limits."""
# update min_temp
if self._attr_hvac_mode == HVACMode.COOL:
attribute = clusters.Thermostat.Attributes.AbsMinCoolSetpointLimit
@@ -534,9 +398,6 @@ class MatterClimate(MatterEntity, ClimateEntity):
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF
)
if feature_map & ThermostatFeature.kPresets:
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
# determine supported hvac modes
if feature_map & ThermostatFeature.kHeating:
self._attr_hvac_modes.append(HVACMode.HEAT)
if feature_map & ThermostatFeature.kCooling:
@@ -579,13 +440,9 @@ DISCOVERY_SCHEMAS = [
optional_attributes=(
clusters.Thermostat.Attributes.FeatureMap,
clusters.Thermostat.Attributes.ControlSequenceOfOperation,
clusters.Thermostat.Attributes.NumberOfPresets,
clusters.Thermostat.Attributes.Occupancy,
clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
clusters.Thermostat.Attributes.OccupiedHeatingSetpoint,
clusters.Thermostat.Attributes.Presets,
clusters.Thermostat.Attributes.PresetTypes,
clusters.Thermostat.Attributes.ActivePresetHandle,
clusters.Thermostat.Attributes.SystemMode,
clusters.Thermostat.Attributes.ThermostatRunningMode,
clusters.Thermostat.Attributes.ThermostatRunningState,
@@ -597,6 +454,5 @@ DISCOVERY_SCHEMAS = [
),
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
allow_multi=True, # also used for sensor entity
allow_none_value=True,
),
]

View File

@@ -1,7 +1,6 @@
"""Constants for the Matter integration."""
import logging
from typing import Final
from chip.clusters import Objects as clusters
@@ -115,5 +114,3 @@ SERVICE_CREDENTIAL_TYPES = [
CRED_TYPE_FINGER_VEIN,
CRED_TYPE_FACE,
]
CONCENTRATION_BECQUERELS_PER_CUBIC_METER: Final = "Bq/m³"

View File

@@ -140,9 +140,6 @@
"pump_status": {
"default": "mdi:pump"
},
"radon_concentration": {
"default": "mdi:radioactive"
},
"tank_percentage": {
"default": "mdi:water-boiler"
},

View File

@@ -48,7 +48,6 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util, slugify
from .const import CONCENTRATION_BECQUERELS_PER_CUBIC_METER
from .entity import MatterEntity, MatterEntityDescription
from .helpers import get_matter
from .models import MatterDiscoverySchema
@@ -745,19 +744,6 @@ DISCOVERY_SCHEMAS = [
clusters.OzoneConcentrationMeasurement.Attributes.MeasuredValue,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="RadonSensor",
native_unit_of_measurement=CONCENTRATION_BECQUERELS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
translation_key="radon_concentration",
),
entity_class=MatterSensor,
required_attributes=(
clusters.RadonConcentrationMeasurement.Attributes.MeasuredValue,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(

View File

@@ -145,16 +145,7 @@
},
"climate": {
"thermostat": {
"name": "Thermostat",
"state_attributes": {
"preset_mode": {
"state": {
"going_to_sleep": "Going to sleep",
"vacation": "Vacation",
"wake": "Wake"
}
}
}
"name": "Thermostat"
}
},
"cover": {
@@ -558,9 +549,6 @@
"pump_speed": {
"name": "Rotation speed"
},
"radon_concentration": {
"name": "Radon concentration"
},
"reactive_current": {
"name": "Reactive current"
},

View File

@@ -7,7 +7,6 @@ from typing import cast
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow, llm
from .application_credentials import authorization_server_context
@@ -43,14 +42,7 @@ async def _create_token_manager(
hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
) -> TokenManager | None:
"""Create a OAuth token manager for the config entry if the server requires authentication."""
try:
implementation = await 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
if not implementation:
if not (implementation := await async_get_config_entry_implementation(hass, entry)):
return None
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)

View File

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

View File

@@ -10,6 +10,6 @@
"iot_class": "local_push",
"loggers": ["music_assistant"],
"quality_scale": "bronze",
"requirements": ["music-assistant-client==1.3.4"],
"requirements": ["music-assistant-client==1.3.3"],
"zeroconf": ["_mass._tcp.local."]
}

View File

@@ -2,11 +2,12 @@
from __future__ import annotations
from http import HTTPStatus
import logging
import secrets
from typing import Any
from aiohttp import ClientError
import aiohttp
import pyatmo
from homeassistant.components import cloud
@@ -18,12 +19,7 @@ from homeassistant.components.webhook import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
@@ -93,9 +89,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except OAuth2TokenRequestReauthError as ex:
raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
except (OAuth2TokenRequestError, ClientError) as ex:
except aiohttp.ClientResponseError as ex:
_LOGGER.warning("API error: %s (%s)", ex.status, ex.message)
if ex.status in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
):
raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
raise ConfigEntryNotReady from ex
required_scopes = api.get_api_scopes(entry.data["auth_implementation"])

View File

@@ -8,8 +8,11 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_AFFECTED_AREAS,
@@ -25,13 +28,13 @@ from .const import (
ATTR_WEB,
CONF_MESSAGE_SLOTS,
CONF_REGIONS,
DOMAIN,
)
from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator
from .entity import NinaEntity
async def async_setup_entry(
_: HomeAssistant,
hass: HomeAssistant,
config_entry: NinaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
@@ -43,7 +46,7 @@ async def async_setup_entry(
message_slots: int = config_entry.data[CONF_MESSAGE_SLOTS]
async_add_entities(
NINAMessage(coordinator, ent, regions[ent], i + 1)
NINAMessage(coordinator, ent, regions[ent], i + 1, config_entry)
for ent in coordinator.data
for i in range(message_slots)
)
@@ -52,7 +55,7 @@ async def async_setup_entry(
PARALLEL_UPDATES = 0
class NINAMessage(NinaEntity, BinarySensorEntity):
class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity):
"""Representation of an NINA warning."""
_attr_device_class = BinarySensorDeviceClass.SAFETY
@@ -64,20 +67,31 @@ class NINAMessage(NinaEntity, BinarySensorEntity):
region: str,
region_name: str,
slot_id: int,
config_entry: ConfigEntry,
) -> None:
"""Initialize."""
super().__init__(coordinator, region, region_name, slot_id)
super().__init__(coordinator)
self._attr_translation_key = "warning"
self._region = region
self._warning_index = slot_id - 1
self._attr_name = f"Warning: {region_name} {slot_id}"
self._attr_unique_id = f"{region}-{slot_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="NINA",
entry_type=DeviceEntryType.SERVICE,
)
@property
def is_on(self) -> bool:
"""Return the state of the sensor."""
if self._get_active_warnings_count() <= self._warning_index:
if len(self.coordinator.data[self._region]) <= self._warning_index:
return False
return self._get_warning_data().is_valid
data = self.coordinator.data[self._region][self._warning_index]
return data.is_valid
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -85,7 +99,7 @@ class NINAMessage(NinaEntity, BinarySensorEntity):
if not self.is_on:
return {}
data = self._get_warning_data()
data = self.coordinator.data[self._region][self._warning_index]
return {
ATTR_HEADLINE: data.headline,

View File

@@ -12,7 +12,6 @@ from pynina import ApiError, Nina
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
@@ -65,12 +64,6 @@ class NINADataUpdateCoordinator(
]
self.area_filter: str = config_entry.data[CONF_FILTERS][CONF_AREA_FILTER]
self.device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="NINA",
entry_type=DeviceEntryType.SERVICE,
)
regions: dict[str, str] = config_entry.data[CONF_REGIONS]
for region in regions:
self._nina.add_region(region)

View File

@@ -1,36 +0,0 @@
"""NINA common entity."""
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import NINADataUpdateCoordinator, NinaWarningData
class NinaEntity(CoordinatorEntity[NINADataUpdateCoordinator]):
"""Base class for NINA entities."""
def __init__(
self,
coordinator: NINADataUpdateCoordinator,
region: str,
region_name: str,
slot_id: int,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._region = region
self._warning_index = slot_id - 1
self._attr_translation_placeholders = {
"region_name": region_name,
"slot_id": str(slot_id),
}
self._attr_device_info = coordinator.device_info
def _get_active_warnings_count(self) -> int:
"""Return the number of active warnings for the region."""
return len(self.coordinator.data[self._region])
def _get_warning_data(self) -> NinaWarningData:
"""Return warning data."""
return self.coordinator.data[self._region][self._warning_index]

View File

@@ -45,13 +45,6 @@
}
}
},
"entity": {
"binary_sensor": {
"warning": {
"name": "Warning: {region_name} {slot_id}"
}
}
},
"options": {
"abort": {
"no_fetch": "[%key:component::nina::config::abort::no_fetch%]",

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aiontfy"],
"quality_scale": "platinum",
"requirements": ["aiontfy==0.8.3"]
"requirements": ["aiontfy==0.8.4"]
}

View File

@@ -76,8 +76,6 @@ class ReceiverManager:
if manager_task in done:
# Something went wrong, so let's return the manager task,
# so that it can be awaited to error out
wait_for_started_task.cancel()
await asyncio.wait((wait_for_started_task,))
return manager_task
return None

View File

@@ -41,7 +41,7 @@ from .const import (
TILT_FACTOR,
ZOOM_FACTOR,
)
from .event_manager import EventManager
from .event import EventManager
from .models import PTZ, Capabilities, DeviceInfo, Profile, Resolution, Video

View File

@@ -1,7 +1,7 @@
{
"domain": "onvif",
"name": "ONVIF",
"codeowners": ["@jterrace"],
"codeowners": ["@hunterjm", "@jterrace"],
"config_flow": true,
"dependencies": ["ffmpeg"],
"dhcp": [

View File

@@ -346,7 +346,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
id=event.item.id,
tool_name="web_search_call",
tool_args={
"action": event.item.action.to_dict(),
"action": event.item.action.to_dict()
if event.item.action
else None,
},
external=True,
)
@@ -360,6 +362,10 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
}
last_role = "tool_result"
elif isinstance(event.item, ImageGenerationCall):
if last_summary_index is not None:
yield {"role": "assistant"}
last_role = "assistant"
last_summary_index = None
yield {"native": event.item}
last_summary_index = -1 # Trigger new assistant message on next turn
elif isinstance(event, ResponseTextDeltaEvent):

View File

@@ -1,22 +1 @@
"""The PJLink integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
_PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up PJLink from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
"""The pjlink component."""

View File

@@ -1,100 +0,0 @@
"""Config flow for the PJLink integration."""
from __future__ import annotations
import logging
from typing import Any
from pypjlink import Projector
from pypjlink.projector import ProjectorError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.helpers import config_validation as cv
from .const import DEFAULT_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_PASSWORD): str,
}
)
def validate_projector_connection(
host: str, port: int | None, password: str | None
) -> str:
"""Validate that we can connect to the projector."""
with Projector.from_address(host, port) as projector:
projector.authenticate(password)
return projector.get_name()
class PJLinkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for PJLink."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
)
try:
projector_name = await self.hass.async_add_executor_job(
validate_projector_connection,
user_input[CONF_HOST],
user_input[CONF_PORT],
user_input.get(CONF_PASSWORD),
)
except TimeoutError, OSError:
errors["base"] = "cannot_connect"
except RuntimeError, ProjectorError:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(title=projector_name, data=user_input)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_import(
self, import_config: dict[str, Any]
) -> ConfigFlowResult:
"""Import a config entry from configuration.yaml."""
host: str = import_config[CONF_HOST]
port: int = import_config.get(CONF_PORT, DEFAULT_PORT)
password: str | None = import_config.get(CONF_PASSWORD)
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
try:
projector_name = await self.hass.async_add_executor_job(
validate_projector_connection, host, port, password
)
except TimeoutError, OSError:
return self.async_abort(reason="cannot_connect")
except RuntimeError, ProjectorError:
return self.async_abort(reason="invalid_auth")
except Exception:
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
else:
import_data: dict[str, Any] = {CONF_HOST: host, CONF_PORT: port}
if password:
import_data[CONF_PASSWORD] = password
return self.async_create_entry(
title=import_config.get(CONF_NAME, projector_name), data=import_data
)

View File

@@ -2,10 +2,9 @@
"domain": "pjlink",
"name": "PJLink",
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/pjlink",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pypjlink"],
"quality_scale": "legacy",
"requirements": ["pypjlink2==1.2.1"]
}

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
from typing import Any
from pypjlink import MUTE_AUDIO, Projector
from pypjlink.projector import ProjectorError
import voluptuous as vol
@@ -14,15 +12,10 @@ from homeassistant.components.media_player import (
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_ENCODING, DEFAULT_ENCODING, DEFAULT_PORT, DOMAIN
@@ -40,60 +33,30 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
)
async def async_setup_platform(
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the PJLink platform."""
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
name = config.get(CONF_NAME)
encoding = config.get(CONF_ENCODING)
password = config.get(CONF_PASSWORD)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result.get('reason')}",
breaks_in_ha_version="2026.11.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "PJLink",
},
)
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
hass_data = hass.data[DOMAIN]
device_label = f"{host}:{port}"
if device_label in hass_data:
return
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2026.11.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "PJLink",
},
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up PJLink media player."""
async_add_entities([PjLinkDevice(entry)], update_before_add=True)
device = PjLinkDevice(host, port, name, encoding, password)
hass_data[device_label] = device
add_entities([device], True)
def format_input_source(input_source_name, input_source_number):
@@ -111,20 +74,20 @@ class PjLinkDevice(MediaPlayerEntity):
| MediaPlayerEntityFeature.SELECT_SOURCE
)
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the PJLink device."""
self._host = entry.data[CONF_HOST]
self._port = entry.data[CONF_PORT]
self._password = entry.data.get(CONF_PASSWORD)
self._source_name_mapping: dict[str, Any] = {}
def __init__(self, host, port, name, encoding, password):
"""Iinitialize the PJLink device."""
self._host = host
self._port = port
self._password = password
self._encoding = encoding
self._source_name_mapping = {}
self._attr_name = entry.title
self._attr_name = name
self._attr_is_volume_muted = False
self._attr_state = MediaPlayerState.OFF
self._attr_source = None
self._attr_source_list = []
self._attr_available = False
self._attr_unique_id = entry.entry_id
def _force_off(self):
self._attr_state = MediaPlayerState.OFF

View File

@@ -1,35 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]"
}
}
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]"
},
"deprecated_yaml_import_issue_invalid_auth": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, invalid authentication details were found. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]"
},
"deprecated_yaml_import_issue_unknown": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an unexpected exception occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]"
}
}
}

View File

@@ -56,7 +56,7 @@ class RadarrCalendarEntity(RadarrEntity, CalendarEntity):
return await self.coordinator.async_get_events(start_date, end_date)
@callback
def _async_write_ha_state(self) -> None:
def async_write_ha_state(self) -> None:
"""Write the state to the state machine."""
if self.coordinator.event:
self._attr_extra_state_attributes = {
@@ -64,4 +64,4 @@ class RadarrCalendarEntity(RadarrEntity, CalendarEntity):
}
else:
self._attr_extra_state_attributes = {}
super()._async_write_ha_state()
super().async_write_ha_state()

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