mirror of
https://github.com/home-assistant/core.git
synced 2026-03-29 20:10:17 +02:00
Compare commits
111 Commits
rc
...
replace-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97323246a3 | ||
|
|
685b921fe7 | ||
|
|
b813aa213f | ||
|
|
79ec3ff484 | ||
|
|
63ba49ce4c | ||
|
|
db561572a6 | ||
|
|
55e76645c6 | ||
|
|
ceafe47cc9 | ||
|
|
966df7ec4d | ||
|
|
85c7bf1dff | ||
|
|
894e9bab0a | ||
|
|
b39c83efd2 | ||
|
|
e855b92b82 | ||
|
|
30ee28a0d3 | ||
|
|
78f6b934bb | ||
|
|
fbef3b27bd | ||
|
|
646f56d015 | ||
|
|
f82d21886a | ||
|
|
f5054d41e1 | ||
|
|
53f64bff49 | ||
|
|
65cb9b8528 | ||
|
|
ecd16d759a | ||
|
|
8498e2a715 | ||
|
|
4fa4ba5ad0 | ||
|
|
a953b697ce | ||
|
|
c543743245 | ||
|
|
5b76fab646 | ||
|
|
6153705b61 | ||
|
|
8632420b8f | ||
|
|
4f89715453 | ||
|
|
8ca8c2191f | ||
|
|
cb43950ccf | ||
|
|
ddfef18183 | ||
|
|
ac65ba7d20 | ||
|
|
d76272d74a | ||
|
|
8e5daeb7dd | ||
|
|
5d7abae490 | ||
|
|
f875c77af0 | ||
|
|
c00a68383c | ||
|
|
5544157d5e | ||
|
|
70aa58913d | ||
|
|
cc363e4ebd | ||
|
|
8d28b399b0 | ||
|
|
fe76fe5408 | ||
|
|
a7de418213 | ||
|
|
e359a8952b | ||
|
|
0a9d4ef138 | ||
|
|
5620cfbfd8 | ||
|
|
fb65cf48c9 | ||
|
|
7fd7b2c203 | ||
|
|
69e691f042 | ||
|
|
f690e6de6a | ||
|
|
ee3c2e6f80 | ||
|
|
5ffe301384 | ||
|
|
e5ad6092d1 | ||
|
|
bd79958d10 | ||
|
|
fe485f853f | ||
|
|
3c67c6087a | ||
|
|
cb7f9b5f49 | ||
|
|
2547563e8c | ||
|
|
213b370693 | ||
|
|
2c9ecb394d | ||
|
|
51a5f5793f | ||
|
|
33f11f2263 | ||
|
|
45069b623c | ||
|
|
5defb4dbff | ||
|
|
bc7c3f0617 | ||
|
|
704c0d1eb0 | ||
|
|
6c864a1725 | ||
|
|
299c6556bb | ||
|
|
f0fc98cb66 | ||
|
|
cd63d14e6f | ||
|
|
30dfd23da8 | ||
|
|
d39ef523b8 | ||
|
|
b6c2fbb8c0 | ||
|
|
758d5469aa | ||
|
|
ea99f88d10 | ||
|
|
0a8f76864c | ||
|
|
ad522d723c | ||
|
|
0f41a311c8 | ||
|
|
412a9a050e | ||
|
|
d5efc3abd5 | ||
|
|
a205623d52 | ||
|
|
8208eecf8c | ||
|
|
f84398eb9c | ||
|
|
aca5adb673 | ||
|
|
f361d01b8b | ||
|
|
d2cef2d26e | ||
|
|
90524e53ec | ||
|
|
668d220400 | ||
|
|
9e28db0535 | ||
|
|
c5807463fd | ||
|
|
f72a9e52f5 | ||
|
|
619582bd03 | ||
|
|
bcc02d7adc | ||
|
|
a9083d5362 | ||
|
|
dd89fa0f5b | ||
|
|
88d0bd5a1d | ||
|
|
a045c2907f | ||
|
|
bcca7655f8 | ||
|
|
269ef5f824 | ||
|
|
c80a9aab71 | ||
|
|
33180a658a | ||
|
|
c5955ada1a | ||
|
|
fd7d936a0d | ||
|
|
84cd137bae | ||
|
|
3a77a638d5 | ||
|
|
599f4f01d0 | ||
|
|
bd298e92d0 | ||
|
|
fabbfd93df | ||
|
|
1ecbc44368 |
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -112,7 +112,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
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@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: OHF-Voice/intents-package
|
||||
|
||||
56
.github/workflows/ci.yaml
vendored
56
.github/workflows/ci.yaml
vendored
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 3
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.4"
|
||||
HA_SHORT_VERSION: "2026.5"
|
||||
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@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
|
||||
uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
|
||||
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@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
|
||||
uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: .mypy_cache
|
||||
key: >-
|
||||
@@ -854,7 +854,7 @@ jobs:
|
||||
- base
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
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@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
report_type: test_results
|
||||
fail_ci_if_error: true
|
||||
|
||||
@@ -13,6 +13,9 @@ 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
|
||||
@@ -25,11 +28,17 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AladdinConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Aladdin Connect Genie from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
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)
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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,36 +54,31 @@ 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 timeout(interval):
|
||||
await client.start()
|
||||
async with AsyncExitStack() as stack:
|
||||
async with timeout(interval):
|
||||
await client.start()
|
||||
stack.push_async_callback(client.stop)
|
||||
|
||||
_LOGGER.debug("Client connected %s", client.host)
|
||||
_LOGGER.debug("Client connected %s", client.host)
|
||||
|
||||
try:
|
||||
for coordinator in coordinators.values():
|
||||
await coordinator.state.start()
|
||||
|
||||
with client.listen(_listen):
|
||||
try:
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_connected()
|
||||
await client.process()
|
||||
finally:
|
||||
await client.stop()
|
||||
await stack.enter_async_context(
|
||||
coordinator.async_monitor_client()
|
||||
)
|
||||
|
||||
_LOGGER.debug("Client disconnected %s", client.host)
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_disconnected()
|
||||
await client.process()
|
||||
finally:
|
||||
_LOGGER.debug("Client disconnected %s", client.host)
|
||||
|
||||
except ConnectionFailed:
|
||||
await asyncio.sleep(interval)
|
||||
pass
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception, aborting arcam client")
|
||||
return
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
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 Client
|
||||
from arcam.fmj.client import AmxDuetResponse, Client, ResponsePacket
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -51,7 +53,7 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
)
|
||||
self.client = client
|
||||
self.state = State(client, zone)
|
||||
self.last_update_success = False
|
||||
self.update_in_progress = False
|
||||
|
||||
name = config_entry.title
|
||||
unique_id = config_entry.unique_id or config_entry.entry_id
|
||||
@@ -74,24 +76,34 @@ 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_data_updated(self) -> None:
|
||||
"""Notify that new data has been received from the device."""
|
||||
self.async_set_updated_data(None)
|
||||
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
|
||||
|
||||
@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())
|
||||
|
||||
@@ -26,3 +26,8 @@ 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
|
||||
|
||||
@@ -142,6 +142,13 @@ 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"
|
||||
|
||||
@@ -122,6 +122,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"battery",
|
||||
"calendar",
|
||||
"climate",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
@@ -147,7 +148,9 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"switch",
|
||||
"temperature",
|
||||
"text",
|
||||
"timer",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
"window",
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
16
homeassistant/components/calendar/condition.py
Normal file
16
homeassistant/components/calendar/condition.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""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
|
||||
14
homeassistant/components/calendar/conditions.yaml
Normal file
14
homeassistant/components/calendar/conditions.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
is_event_active:
|
||||
target:
|
||||
entity:
|
||||
- domain: calendar
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_event_active": {
|
||||
"condition": "mdi:calendar-check"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:calendar",
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
{
|
||||
"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%]",
|
||||
@@ -46,6 +62,12 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_offset_type": {
|
||||
"options": {
|
||||
"after": "After",
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -44,18 +44,18 @@ class DemoRemote(RemoteEntity):
|
||||
return {"last_command_sent": self._last_command_sent}
|
||||
return None
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the remote on."""
|
||||
self._attr_is_on = True
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the remote off."""
|
||||
self._attr_is_on = False
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
async def async_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.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -61,12 +61,12 @@ class DemoSwitch(SwitchEntity):
|
||||
name=device_name,
|
||||
)
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
self._attr_is_on = True
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
self._attr_is_on = False
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -4,9 +4,12 @@ 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 FitbitScope
|
||||
from .const import DOMAIN, FitbitScope
|
||||
from .coordinator import FitbitConfigEntry, FitbitData, FitbitDeviceCoordinator
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import config_from_entry_data
|
||||
@@ -16,11 +19,17 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool:
|
||||
"""Set up fitbit from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
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")
|
||||
|
||||
@@ -121,5 +121,10 @@
|
||||
"name": "Water"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
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 in devices_coordinator.data
|
||||
for device_id, device in devices_coordinator.data.items()
|
||||
}
|
||||
await asyncio.gather(
|
||||
*(
|
||||
@@ -38,6 +38,35 @@ 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
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
|
||||
@@ -32,7 +33,7 @@ class FreshrData:
|
||||
type FreshrConfigEntry = ConfigEntry[FreshrData]
|
||||
|
||||
|
||||
class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
|
||||
class FreshrDevicesCoordinator(DataUpdateCoordinator[dict[str, DeviceSummary]]):
|
||||
"""Coordinator that refreshes the device list once an hour."""
|
||||
|
||||
config_entry: FreshrConfigEntry
|
||||
@@ -48,7 +49,7 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
|
||||
)
|
||||
self.client = FreshrClient(session=async_create_clientsession(hass))
|
||||
|
||||
async def _async_update_data(self) -> list[DeviceSummary]:
|
||||
async def _async_update_data(self) -> dict[str, 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]
|
||||
@@ -68,8 +69,23 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
else:
|
||||
return devices
|
||||
|
||||
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
|
||||
|
||||
|
||||
class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]):
|
||||
|
||||
@@ -45,7 +45,9 @@ rules:
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration connects to a cloud service; no local network discovery is possible.
|
||||
discovery: todo
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: No local network discovery of devices is possible (no zeroconf, mdns or other discovery mechanisms).
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
@@ -53,7 +55,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
@@ -64,7 +66,7 @@ rules:
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow.
|
||||
stale-devices: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -112,26 +112,43 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Fresh-r sensors from a config entry."""
|
||||
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,
|
||||
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]
|
||||
)
|
||||
for description in descriptions
|
||||
)
|
||||
async_add_entities(entities)
|
||||
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))
|
||||
|
||||
|
||||
class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -24,6 +24,9 @@ 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
|
||||
@@ -88,11 +91,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo
|
||||
_LOGGER.error("Configuration error in %s: %s", YAML_DEVICES, str(err))
|
||||
return False
|
||||
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
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.
|
||||
|
||||
@@ -57,6 +57,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientError
|
||||
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
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, discovery, intent
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
@@ -51,13 +56,11 @@ async def async_setup_entry(
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
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:
|
||||
except OAuth2TokenRequestReauthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="reauth_required"
|
||||
) from err
|
||||
except (OAuth2TokenRequestError, ClientError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
mem_storage = InMemoryStorage(hass)
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -26,7 +25,11 @@ 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, ServiceValidationError
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
@@ -79,9 +82,8 @@ async def async_send_text_commands(
|
||||
session = entry.runtime_data.session
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
entry.async_start_reauth(hass)
|
||||
except OAuth2TokenRequestReauthError:
|
||||
entry.async_start_reauth(hass)
|
||||
raise
|
||||
|
||||
credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
|
||||
|
||||
@@ -33,11 +33,18 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GooglePhotosConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Google Photos from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
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)
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
"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}"
|
||||
}
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
from typing import Any, NamedTuple, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
@@ -41,35 +39,23 @@ from homeassistant.components.http import (
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_ID,
|
||||
ATTR_NAME,
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
HASSIO_USER_NAME,
|
||||
SERVER_PORT,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
Event,
|
||||
HassJob,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
async_get_hass_or_none,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.core import Event, HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery_flow,
|
||||
issue_registry as ir,
|
||||
selector,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.async_ import create_eager_task
|
||||
from homeassistant.util.dt import now
|
||||
|
||||
# config_flow, diagnostics, system_health, and entity platforms are imported to
|
||||
# ensure other dependencies that wait for hassio are not waiting
|
||||
@@ -92,19 +78,7 @@ from .auth import async_setup_auth_view
|
||||
from .config import HassioConfig
|
||||
from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_ADDON,
|
||||
ATTR_ADDONS,
|
||||
ATTR_APP,
|
||||
ATTR_APPS,
|
||||
ATTR_COMPRESSED,
|
||||
ATTR_FOLDERS,
|
||||
ATTR_HOMEASSISTANT,
|
||||
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
|
||||
ATTR_INPUT,
|
||||
ATTR_LOCATION,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_REPOSITORIES,
|
||||
ATTR_SLUG,
|
||||
DATA_ADDONS_LIST,
|
||||
DATA_COMPONENT,
|
||||
DATA_CONFIG_STORE,
|
||||
@@ -118,7 +92,6 @@ from .const import (
|
||||
DATA_SUPERVISOR_INFO,
|
||||
DOMAIN,
|
||||
HASSIO_UPDATE_INTERVAL,
|
||||
SupervisorEntityModel,
|
||||
)
|
||||
from .coordinator import (
|
||||
HassioDataUpdateCoordinator,
|
||||
@@ -136,15 +109,11 @@ from .coordinator import (
|
||||
get_supervisor_stats,
|
||||
)
|
||||
from .discovery import async_setup_discovery_view
|
||||
from .handler import (
|
||||
HassIO,
|
||||
HassioAPIError,
|
||||
async_update_diagnostics,
|
||||
get_supervisor_client,
|
||||
)
|
||||
from .handler import HassIO, async_update_diagnostics, get_supervisor_client
|
||||
from .http import HassIOView
|
||||
from .ingress import async_setup_ingress_view
|
||||
from .issues import SupervisorIssues
|
||||
from .services import async_setup_services
|
||||
from .websocket_api import async_load_websocket_api
|
||||
|
||||
# Expose the future safe name now so integrations can use it
|
||||
@@ -190,23 +159,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
SERVICE_ADDON_START = "addon_start"
|
||||
SERVICE_ADDON_STOP = "addon_stop"
|
||||
SERVICE_ADDON_RESTART = "addon_restart"
|
||||
SERVICE_ADDON_STDIN = "addon_stdin"
|
||||
SERVICE_APP_START = "app_start"
|
||||
SERVICE_APP_STOP = "app_stop"
|
||||
SERVICE_APP_RESTART = "app_restart"
|
||||
SERVICE_APP_STDIN = "app_stdin"
|
||||
SERVICE_HOST_SHUTDOWN = "host_shutdown"
|
||||
SERVICE_HOST_REBOOT = "host_reboot"
|
||||
SERVICE_BACKUP_FULL = "backup_full"
|
||||
SERVICE_BACKUP_PARTIAL = "backup_partial"
|
||||
SERVICE_RESTORE_FULL = "restore_full"
|
||||
SERVICE_RESTORE_PARTIAL = "restore_partial"
|
||||
SERVICE_MOUNT_RELOAD = "mount_reload"
|
||||
|
||||
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
|
||||
|
||||
DEPRECATION_URL = (
|
||||
"https://www.home-assistant.io/blog/2025/05/22/"
|
||||
@@ -214,148 +166,11 @@ DEPRECATION_URL = (
|
||||
)
|
||||
|
||||
|
||||
def valid_addon(value: Any) -> str:
|
||||
"""Validate value is a valid addon slug."""
|
||||
value = VALID_ADDON_SLUG(value)
|
||||
hass = async_get_hass_or_none()
|
||||
|
||||
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
|
||||
raise vol.Invalid("Not a valid app slug")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA_NO_DATA = vol.Schema({})
|
||||
|
||||
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
|
||||
|
||||
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
|
||||
|
||||
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_FULL = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
): cv.string,
|
||||
vol.Optional(ATTR_PASSWORD): cv.string,
|
||||
vol.Optional(ATTR_COMPRESSED): cv.boolean,
|
||||
vol.Optional(ATTR_LOCATION): vol.All(
|
||||
cv.string, lambda v: None if v == "/backup" else v
|
||||
),
|
||||
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_RESTORE_FULL = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_SLUG): cv.slug,
|
||||
vol.Optional(ATTR_PASSWORD): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_MOUNT_RELOAD = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
|
||||
selector.DeviceSelectorConfig(
|
||||
filter=selector.DeviceFilterSelectorConfig(
|
||||
integration=DOMAIN,
|
||||
model=SupervisorEntityModel.MOUNT,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_32_bit() -> bool:
|
||||
size = struct.calcsize("P")
|
||||
return size * 8 == 32
|
||||
|
||||
|
||||
class APIEndpointSettings(NamedTuple):
|
||||
"""Settings for API endpoint."""
|
||||
|
||||
command: str
|
||||
schema: vol.Schema
|
||||
timeout: int | None = 60
|
||||
pass_data: bool = False
|
||||
|
||||
|
||||
MAP_SERVICE_API = {
|
||||
# Legacy addon services
|
||||
SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_STDIN: APIEndpointSettings(
|
||||
"/addons/{addon}/stdin", SCHEMA_ADDON_STDIN
|
||||
),
|
||||
# New app services
|
||||
SERVICE_APP_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_APP),
|
||||
SERVICE_APP_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_APP),
|
||||
SERVICE_APP_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_APP),
|
||||
SERVICE_APP_STDIN: APIEndpointSettings("/addons/{addon}/stdin", SCHEMA_APP_STDIN),
|
||||
SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA),
|
||||
SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA),
|
||||
SERVICE_BACKUP_FULL: APIEndpointSettings(
|
||||
"/backups/new/full",
|
||||
SCHEMA_BACKUP_FULL,
|
||||
None,
|
||||
True,
|
||||
),
|
||||
SERVICE_BACKUP_PARTIAL: APIEndpointSettings(
|
||||
"/backups/new/partial",
|
||||
SCHEMA_BACKUP_PARTIAL,
|
||||
None,
|
||||
True,
|
||||
),
|
||||
SERVICE_RESTORE_FULL: APIEndpointSettings(
|
||||
"/backups/{slug}/restore/full",
|
||||
SCHEMA_RESTORE_FULL,
|
||||
None,
|
||||
True,
|
||||
),
|
||||
SERVICE_RESTORE_PARTIAL: APIEndpointSettings(
|
||||
"/backups/{slug}/restore/partial",
|
||||
SCHEMA_RESTORE_PARTIAL,
|
||||
None,
|
||||
True,
|
||||
),
|
||||
}
|
||||
|
||||
HARDWARE_INTEGRATIONS = {
|
||||
"green": "homeassistant_green",
|
||||
"odroid-c2": "hardkernel",
|
||||
@@ -397,7 +212,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
|
||||
host = os.environ["SUPERVISOR"]
|
||||
websession = async_get_clientsession(hass)
|
||||
hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host)
|
||||
hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host)
|
||||
supervisor_client = get_supervisor_client(hass)
|
||||
|
||||
try:
|
||||
@@ -510,74 +325,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
|
||||
issues_task = hass.async_create_task(issues.setup(), eager_start=True)
|
||||
|
||||
async def async_service_handler(service: ServiceCall) -> None:
|
||||
"""Handle service calls for Hass.io."""
|
||||
api_endpoint = MAP_SERVICE_API[service.service]
|
||||
|
||||
data = service.data.copy()
|
||||
addon = data.pop(ATTR_APP, None) or data.pop(ATTR_ADDON, None)
|
||||
slug = data.pop(ATTR_SLUG, None)
|
||||
|
||||
if addons := data.pop(ATTR_APPS, None) or data.pop(ATTR_ADDONS, None):
|
||||
data[ATTR_ADDONS] = addons
|
||||
|
||||
payload = None
|
||||
|
||||
# Pass data to Hass.io API
|
||||
if service.service in (SERVICE_ADDON_STDIN, SERVICE_APP_STDIN):
|
||||
payload = data[ATTR_INPUT]
|
||||
elif api_endpoint.pass_data:
|
||||
payload = data
|
||||
|
||||
# Call API
|
||||
# The exceptions are logged properly in hassio.send_command
|
||||
with suppress(HassioAPIError):
|
||||
await hassio.send_command(
|
||||
api_endpoint.command.format(addon=addon, slug=slug),
|
||||
payload=payload,
|
||||
timeout=api_endpoint.timeout,
|
||||
)
|
||||
|
||||
for service, settings in MAP_SERVICE_API.items():
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_service_handler, schema=settings.schema
|
||||
)
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
|
||||
async def async_mount_reload(service: ServiceCall) -> None:
|
||||
"""Handle service calls for Hass.io."""
|
||||
coordinator: HassioDataUpdateCoordinator | None = None
|
||||
|
||||
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_unknown_device_id",
|
||||
)
|
||||
|
||||
if (
|
||||
device.name is None
|
||||
or device.model != SupervisorEntityModel.MOUNT
|
||||
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
|
||||
or coordinator.entry_id not in device.config_entries
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_invalid_device",
|
||||
)
|
||||
|
||||
try:
|
||||
await supervisor_client.mounts.reload_mount(device.name)
|
||||
except SupervisorError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_error",
|
||||
translation_placeholders={"name": device.name, "error": str(error)},
|
||||
) from error
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
|
||||
)
|
||||
# Register services
|
||||
async_setup_services(hass, supervisor_client)
|
||||
|
||||
async def update_info_data(_: datetime | None = None) -> None:
|
||||
"""Update last available supervisor information."""
|
||||
|
||||
@@ -26,7 +26,7 @@ from aiohasupervisor.models import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .handler import HassioAPIError, get_supervisor_client
|
||||
from .handler import get_supervisor_client
|
||||
|
||||
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]]
|
||||
type _ReturnFuncType[_T, **_P, _R] = Callable[
|
||||
@@ -36,18 +36,15 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[
|
||||
|
||||
def api_error[_AddonManagerT: AddonManager, **_P, _R](
|
||||
error_message: str,
|
||||
*,
|
||||
expected_error_type: type[HassioAPIError | SupervisorError] | None = None,
|
||||
) -> Callable[
|
||||
[_FuncType[_AddonManagerT, _P, _R]], _ReturnFuncType[_AddonManagerT, _P, _R]
|
||||
]:
|
||||
"""Handle HassioAPIError and raise a specific AddonError."""
|
||||
error_type = expected_error_type or (HassioAPIError, SupervisorError)
|
||||
"""Handle SupervisorError and raise a specific AddonError."""
|
||||
|
||||
def handle_hassio_api_error(
|
||||
def handle_supervisor_error(
|
||||
func: _FuncType[_AddonManagerT, _P, _R],
|
||||
) -> _ReturnFuncType[_AddonManagerT, _P, _R]:
|
||||
"""Handle a HassioAPIError."""
|
||||
"""Handle a SupervisorError."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(
|
||||
@@ -56,7 +53,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
|
||||
"""Wrap an add-on manager method."""
|
||||
try:
|
||||
return_value = await func(self, *args, **kwargs)
|
||||
except error_type as err:
|
||||
except SupervisorError as err:
|
||||
raise AddonError(
|
||||
f"{error_message.format(addon_name=self.addon_name)}: {err}"
|
||||
) from err
|
||||
@@ -65,7 +62,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
|
||||
|
||||
return wrapper
|
||||
|
||||
return handle_hassio_api_error
|
||||
return handle_supervisor_error
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -128,10 +125,7 @@ class AddonManager:
|
||||
)
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to get the {addon_name} app discovery info",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to get the {addon_name} app discovery info")
|
||||
async def async_get_addon_discovery_info(self) -> dict:
|
||||
"""Return add-on discovery info."""
|
||||
discovery_info = next(
|
||||
@@ -148,10 +142,7 @@ class AddonManager:
|
||||
|
||||
return discovery_info.config
|
||||
|
||||
@api_error(
|
||||
"Failed to get the {addon_name} app info",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to get the {addon_name} app info")
|
||||
async def async_get_addon_info(self) -> AddonInfo:
|
||||
"""Return and cache manager add-on info."""
|
||||
addon_store_info = await self._supervisor_client.store.addon_info(
|
||||
@@ -199,19 +190,14 @@ class AddonManager:
|
||||
version=addon_info.version,
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to set the {addon_name} app options",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to set the {addon_name} app options")
|
||||
async def async_set_addon_options(self, config: dict) -> None:
|
||||
"""Set manager add-on options."""
|
||||
await self._supervisor_client.addons.set_addon_options(
|
||||
self.addon_slug, AddonsOptions(config=config)
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to install the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
@api_error("Failed to install the {addon_name} app")
|
||||
async def async_install_addon(self) -> None:
|
||||
"""Install the managed add-on."""
|
||||
try:
|
||||
@@ -221,10 +207,7 @@ class AddonManager:
|
||||
f"{self.addon_name} app is not available: {err!s}"
|
||||
) from None
|
||||
|
||||
@api_error(
|
||||
"Failed to uninstall the {addon_name} app",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to uninstall the {addon_name} app")
|
||||
async def async_uninstall_addon(self) -> None:
|
||||
"""Uninstall the managed add-on."""
|
||||
await self._supervisor_client.addons.uninstall_addon(self.addon_slug)
|
||||
@@ -259,31 +242,22 @@ class AddonManager:
|
||||
self.addon_slug, StoreAddonUpdate(backup=False)
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to start the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
@api_error("Failed to start the {addon_name} app")
|
||||
async def async_start_addon(self) -> None:
|
||||
"""Start the managed add-on."""
|
||||
await self._supervisor_client.addons.start_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to restart the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
@api_error("Failed to restart the {addon_name} app")
|
||||
async def async_restart_addon(self) -> None:
|
||||
"""Restart the managed add-on."""
|
||||
await self._supervisor_client.addons.restart_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to stop the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
@api_error("Failed to stop the {addon_name} app")
|
||||
async def async_stop_addon(self) -> None:
|
||||
"""Stop the managed add-on."""
|
||||
await self._supervisor_client.addons.stop_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to create a backup of the {addon_name} app",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to create a backup of the {addon_name} app")
|
||||
async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None:
|
||||
"""Create a partial backup of the managed add-on."""
|
||||
if addon_info:
|
||||
|
||||
442
homeassistant/components/hassio/services.py
Normal file
442
homeassistant/components/hassio/services.py
Normal file
@@ -0,0 +1,442 @@
|
||||
"""Set up Supervisor services."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorClient, SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
FullBackupOptions,
|
||||
FullRestoreOptions,
|
||||
PartialBackupOptions,
|
||||
PartialRestoreOptions,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
async_get_hass_or_none,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
selector,
|
||||
)
|
||||
from homeassistant.util.dt import now
|
||||
|
||||
from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_ADDON,
|
||||
ATTR_ADDONS,
|
||||
ATTR_APP,
|
||||
ATTR_APPS,
|
||||
ATTR_COMPRESSED,
|
||||
ATTR_FOLDERS,
|
||||
ATTR_HOMEASSISTANT,
|
||||
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
|
||||
ATTR_INPUT,
|
||||
ATTR_LOCATION,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_SLUG,
|
||||
DOMAIN,
|
||||
SupervisorEntityModel,
|
||||
)
|
||||
from .coordinator import HassioDataUpdateCoordinator, get_addons_info
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_ADDON_START = "addon_start"
|
||||
SERVICE_ADDON_STOP = "addon_stop"
|
||||
SERVICE_ADDON_RESTART = "addon_restart"
|
||||
SERVICE_ADDON_STDIN = "addon_stdin"
|
||||
SERVICE_APP_START = "app_start"
|
||||
SERVICE_APP_STOP = "app_stop"
|
||||
SERVICE_APP_RESTART = "app_restart"
|
||||
SERVICE_APP_STDIN = "app_stdin"
|
||||
SERVICE_HOST_SHUTDOWN = "host_shutdown"
|
||||
SERVICE_HOST_REBOOT = "host_reboot"
|
||||
SERVICE_BACKUP_FULL = "backup_full"
|
||||
SERVICE_BACKUP_PARTIAL = "backup_partial"
|
||||
SERVICE_RESTORE_FULL = "restore_full"
|
||||
SERVICE_RESTORE_PARTIAL = "restore_partial"
|
||||
SERVICE_MOUNT_RELOAD = "mount_reload"
|
||||
|
||||
|
||||
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
|
||||
|
||||
|
||||
def valid_addon(value: Any) -> str:
|
||||
"""Validate value is a valid addon slug."""
|
||||
value = VALID_ADDON_SLUG(value)
|
||||
hass = async_get_hass_or_none()
|
||||
|
||||
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
|
||||
raise vol.Invalid("Not a valid app slug")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA_NO_DATA = vol.Schema({})
|
||||
|
||||
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
|
||||
|
||||
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
|
||||
|
||||
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_FULL = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
): cv.string,
|
||||
vol.Optional(ATTR_PASSWORD): cv.string,
|
||||
vol.Optional(ATTR_COMPRESSED): cv.boolean,
|
||||
vol.Optional(ATTR_LOCATION): vol.All(
|
||||
cv.string, lambda v: None if v == "/backup" else v
|
||||
),
|
||||
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_RESTORE_FULL = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_SLUG): cv.slug,
|
||||
vol.Optional(ATTR_PASSWORD): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_MOUNT_RELOAD = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
|
||||
selector.DeviceSelectorConfig(
|
||||
filter=selector.DeviceFilterSelectorConfig(
|
||||
integration=DOMAIN,
|
||||
model=SupervisorEntityModel.MOUNT,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register the Supervisor services."""
|
||||
async_register_app_services(hass, supervisor_client)
|
||||
async_register_host_services(hass, supervisor_client)
|
||||
async_register_backup_restore_services(hass, supervisor_client)
|
||||
async_register_network_storage_services(hass, supervisor_client)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_app_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register app services."""
|
||||
simple_app_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
|
||||
SERVICE_APP_START: ("start", supervisor_client.addons.start_addon),
|
||||
SERVICE_APP_RESTART: ("restart", supervisor_client.addons.restart_addon),
|
||||
SERVICE_APP_STOP: ("stop", supervisor_client.addons.stop_addon),
|
||||
}
|
||||
|
||||
async def async_simple_app_service_handler(service: ServiceCall) -> None:
|
||||
"""Handles app services which only take a slug and have no response."""
|
||||
action, api_method = simple_app_services[service.service]
|
||||
app_slug = service.data[ATTR_APP]
|
||||
|
||||
try:
|
||||
await api_method(app_slug)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to {action} app {app_slug}: {err}"
|
||||
) from err
|
||||
|
||||
for service in simple_app_services:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP
|
||||
)
|
||||
|
||||
async def async_app_stdin_service_handler(service: ServiceCall) -> None:
|
||||
"""Handles app stdin service."""
|
||||
app_slug = service.data[ATTR_APP]
|
||||
data: dict | str = service.data[ATTR_INPUT]
|
||||
|
||||
# For backwards compatibility the payload here must be valid json
|
||||
# This is sensible when a dictionary is provided, it must be serialized
|
||||
# If user provides a string though, we wrap it in quotes before encoding
|
||||
# This is purely for legacy reasons, Supervisor has no json requirement
|
||||
# Supervisor just hands the raw request as binary to the container
|
||||
data = json.dumps(data)
|
||||
payload = data.encode(encoding="utf-8")
|
||||
|
||||
try:
|
||||
await supervisor_client.addons.write_addon_stdin(app_slug, payload)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to write stdin to app {app_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_APP_STDIN,
|
||||
async_app_stdin_service_handler,
|
||||
schema=SCHEMA_APP_STDIN,
|
||||
)
|
||||
|
||||
# LEGACY - Register equivalent addon services for compatibility
|
||||
simple_addon_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
|
||||
SERVICE_ADDON_START: ("start", supervisor_client.addons.start_addon),
|
||||
SERVICE_ADDON_RESTART: ("restart", supervisor_client.addons.restart_addon),
|
||||
SERVICE_ADDON_STOP: ("stop", supervisor_client.addons.stop_addon),
|
||||
}
|
||||
|
||||
async def async_simple_addon_service_handler(service: ServiceCall) -> None:
|
||||
"""Handles addon services which only take a slug and have no response."""
|
||||
action, api_method = simple_addon_services[service.service]
|
||||
addon_slug = service.data[ATTR_ADDON]
|
||||
|
||||
try:
|
||||
await api_method(addon_slug)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to {action} app {addon_slug}: {err}"
|
||||
) from err
|
||||
|
||||
for service in simple_addon_services:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_simple_addon_service_handler, schema=SCHEMA_ADDON
|
||||
)
|
||||
|
||||
async def async_addon_stdin_service_handler(service: ServiceCall) -> None:
|
||||
"""Handles addon stdin service."""
|
||||
addon_slug = service.data[ATTR_ADDON]
|
||||
data: dict | str = service.data[ATTR_INPUT]
|
||||
|
||||
# See explanation for why we make strings into json in async_app_stdin_service_handler
|
||||
data = json.dumps(data)
|
||||
payload = data.encode(encoding="utf-8")
|
||||
|
||||
try:
|
||||
await supervisor_client.addons.write_addon_stdin(addon_slug, payload)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to write stdin to app {addon_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADDON_STDIN,
|
||||
async_addon_stdin_service_handler,
|
||||
schema=SCHEMA_ADDON_STDIN,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_host_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register host services."""
|
||||
simple_host_services: dict[str, tuple[str, Callable[[], Awaitable[None]]]] = {
|
||||
SERVICE_HOST_REBOOT: ("reboot", supervisor_client.host.reboot),
|
||||
SERVICE_HOST_SHUTDOWN: ("shutdown", supervisor_client.host.shutdown),
|
||||
}
|
||||
|
||||
async def async_simple_host_service_handler(service: ServiceCall) -> None:
|
||||
"""Handler for host services that take no input and return no response."""
|
||||
action, api_method = simple_host_services[service.service]
|
||||
try:
|
||||
await api_method()
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(f"Failed to {action} the host: {err}") from err
|
||||
|
||||
for service in simple_host_services:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_simple_host_service_handler, schema=SCHEMA_NO_DATA
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_backup_restore_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register backup and restore services."""
|
||||
|
||||
async def async_full_backup_service_handler(
|
||||
service: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handler for create full backup service. Returns the new backup's ID."""
|
||||
options = FullBackupOptions(**service.data)
|
||||
try:
|
||||
backup = await supervisor_client.backups.full_backup(options)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to create full backup {options.name}: {err}"
|
||||
) from err
|
||||
|
||||
return {"backup": backup.slug}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_BACKUP_FULL,
|
||||
async_full_backup_service_handler,
|
||||
schema=SCHEMA_BACKUP_FULL,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
async def async_partial_backup_service_handler(
|
||||
service: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handler for create partial backup service. Returns the new backup's ID."""
|
||||
data = service.data.copy()
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
options = PartialBackupOptions(**data)
|
||||
|
||||
try:
|
||||
backup = await supervisor_client.backups.partial_backup(options)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to create partial backup {options.name}: {err}"
|
||||
) from err
|
||||
|
||||
return {"backup": backup.slug}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_BACKUP_PARTIAL,
|
||||
async_partial_backup_service_handler,
|
||||
schema=SCHEMA_BACKUP_PARTIAL,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
async def async_full_restore_service_handler(service: ServiceCall) -> None:
|
||||
"""Handler for full restore service."""
|
||||
backup_slug = service.data[ATTR_SLUG]
|
||||
options: FullRestoreOptions | None = None
|
||||
if ATTR_PASSWORD in service.data:
|
||||
options = FullRestoreOptions(password=service.data[ATTR_PASSWORD])
|
||||
|
||||
try:
|
||||
await supervisor_client.backups.full_restore(backup_slug, options)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to full restore from backup {backup_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RESTORE_FULL,
|
||||
async_full_restore_service_handler,
|
||||
schema=SCHEMA_RESTORE_FULL,
|
||||
)
|
||||
|
||||
async def async_partial_restore_service_handler(service: ServiceCall) -> None:
|
||||
"""Handler for partial restore service."""
|
||||
data = service.data.copy()
|
||||
backup_slug = data.pop(ATTR_SLUG)
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
options = PartialRestoreOptions(**data)
|
||||
|
||||
try:
|
||||
await supervisor_client.backups.partial_restore(backup_slug, options)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to partial restore from backup {backup_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RESTORE_PARTIAL,
|
||||
async_partial_restore_service_handler,
|
||||
schema=SCHEMA_RESTORE_PARTIAL,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_network_storage_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register network storage (or mount) services."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
|
||||
async def async_mount_reload(service: ServiceCall) -> None:
|
||||
"""Handle service calls for Hass.io."""
|
||||
coordinator: HassioDataUpdateCoordinator | None = None
|
||||
|
||||
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_unknown_device_id",
|
||||
)
|
||||
|
||||
if (
|
||||
device.name is None
|
||||
or device.model != SupervisorEntityModel.MOUNT
|
||||
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
|
||||
or coordinator.entry_id not in device.config_entries
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_invalid_device",
|
||||
)
|
||||
|
||||
try:
|
||||
await supervisor_client.mounts.reload_mount(device.name)
|
||||
except SupervisorError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_error",
|
||||
translation_placeholders={"name": device.name, "error": str(error)},
|
||||
) from error
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
|
||||
)
|
||||
@@ -18,7 +18,6 @@ from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
)
|
||||
|
||||
from . import HassioAPIError
|
||||
from .config import HassioUpdateParametersDict
|
||||
from .const import (
|
||||
ATTR_DATA,
|
||||
@@ -40,6 +39,7 @@ from .const import (
|
||||
WS_TYPE_SUBSCRIBE,
|
||||
)
|
||||
from .coordinator import get_addons_list
|
||||
from .handler import HassioAPIError
|
||||
from .update_helper import update_addon, update_core
|
||||
|
||||
SCHEMA_WEBSOCKET_EVENT = vol.Schema(
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homematicip.base.enums import SmokeDetectorAlarmType, WindowState
|
||||
from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState
|
||||
from homematicip.base.functionalChannels import MultiModeInputChannel
|
||||
from homematicip.device import (
|
||||
AccelerationSensor,
|
||||
@@ -74,6 +74,30 @@ 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,
|
||||
@@ -122,6 +146,9 @@ 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):
|
||||
@@ -298,6 +325,55 @@ 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."""
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@ 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,
|
||||
@@ -20,11 +27,17 @@ async def async_setup_entry(
|
||||
"""Set up the HomematicIP button from a config entry."""
|
||||
hap = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
entities: list[ButtonEntity] = [
|
||||
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):
|
||||
@@ -38,3 +51,16 @@ 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()
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["thinqconnect"],
|
||||
"requirements": ["thinqconnect==1.0.9"]
|
||||
"requirements": ["thinqconnect==1.0.11"]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ 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,
|
||||
@@ -42,6 +46,18 @@ 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.
|
||||
@@ -159,7 +175,6 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
}
|
||||
|
||||
SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum
|
||||
ControlSequenceEnum = clusters.Thermostat.Enums.ControlSequenceOfOperationEnum
|
||||
ThermostatFeature = clusters.Thermostat.Bitmaps.Feature
|
||||
|
||||
|
||||
@@ -195,10 +210,22 @@ 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)
|
||||
@@ -243,6 +270,34 @@ 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."""
|
||||
|
||||
@@ -267,10 +322,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 (
|
||||
@@ -282,6 +337,81 @@ 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
|
||||
@@ -333,7 +463,10 @@ class MatterClimate(MatterEntity, ClimateEntity):
|
||||
self._attr_hvac_action = HVACAction.FAN
|
||||
else:
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
# update target temperature high/low
|
||||
|
||||
@callback
|
||||
def _update_target_temperatures(self) -> None:
|
||||
"""Update target temperature or temperature range."""
|
||||
supports_range = (
|
||||
self._attr_supported_features
|
||||
& ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
@@ -359,6 +492,9 @@ 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
|
||||
@@ -398,6 +534,9 @@ 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:
|
||||
@@ -440,9 +579,13 @@ 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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Constants for the Matter integration."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from chip.clusters import Objects as clusters
|
||||
|
||||
@@ -114,3 +115,5 @@ SERVICE_CREDENTIAL_TYPES = [
|
||||
CRED_TYPE_FINGER_VEIN,
|
||||
CRED_TYPE_FACE,
|
||||
]
|
||||
|
||||
CONCENTRATION_BECQUERELS_PER_CUBIC_METER: Final = "Bq/m³"
|
||||
|
||||
@@ -140,6 +140,9 @@
|
||||
"pump_status": {
|
||||
"default": "mdi:pump"
|
||||
},
|
||||
"radon_concentration": {
|
||||
"default": "mdi:radioactive"
|
||||
},
|
||||
"tank_percentage": {
|
||||
"default": "mdi:water-boiler"
|
||||
},
|
||||
|
||||
@@ -48,6 +48,7 @@ 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
|
||||
@@ -744,6 +745,19 @@ 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(
|
||||
|
||||
@@ -145,7 +145,16 @@
|
||||
},
|
||||
"climate": {
|
||||
"thermostat": {
|
||||
"name": "Thermostat"
|
||||
"name": "Thermostat",
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"going_to_sleep": "Going to sleep",
|
||||
"vacation": "Vacation",
|
||||
"wake": "Wake"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cover": {
|
||||
@@ -549,6 +558,9 @@
|
||||
"pump_speed": {
|
||||
"name": "Rotation speed"
|
||||
},
|
||||
"radon_concentration": {
|
||||
"name": "Radon concentration"
|
||||
},
|
||||
"reactive_current": {
|
||||
"name": "Reactive current"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -42,7 +43,14 @@ 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."""
|
||||
if not (implementation := await async_get_config_entry_implementation(hass, entry)):
|
||||
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:
|
||||
return None
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
@@ -56,5 +56,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["music_assistant"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["music-assistant-client==1.3.3"],
|
||||
"requirements": ["music-assistant-client==1.3.4"],
|
||||
"zeroconf": ["_mass._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientError
|
||||
import pyatmo
|
||||
|
||||
from homeassistant.components import cloud
|
||||
@@ -19,7 +18,12 @@ 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
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
@@ -89,14 +93,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
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
|
||||
except OAuth2TokenRequestReauthError as ex:
|
||||
raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
|
||||
except (OAuth2TokenRequestError, ClientError) as ex:
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
required_scopes = api.get_api_scopes(entry.data["auth_implementation"])
|
||||
|
||||
@@ -8,11 +8,8 @@ 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,
|
||||
@@ -28,13 +25,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(
|
||||
hass: HomeAssistant,
|
||||
_: HomeAssistant,
|
||||
config_entry: NinaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
@@ -46,7 +43,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, config_entry)
|
||||
NINAMessage(coordinator, ent, regions[ent], i + 1)
|
||||
for ent in coordinator.data
|
||||
for i in range(message_slots)
|
||||
)
|
||||
@@ -55,7 +52,7 @@ async def async_setup_entry(
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity):
|
||||
class NINAMessage(NinaEntity, BinarySensorEntity):
|
||||
"""Representation of an NINA warning."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.SAFETY
|
||||
@@ -67,31 +64,20 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti
|
||||
region: str,
|
||||
region_name: str,
|
||||
slot_id: int,
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
super().__init__(coordinator, region, region_name, slot_id)
|
||||
|
||||
self._region = region
|
||||
self._warning_index = slot_id - 1
|
||||
|
||||
self._attr_name = f"Warning: {region_name} {slot_id}"
|
||||
self._attr_translation_key = "warning"
|
||||
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 len(self.coordinator.data[self._region]) <= self._warning_index:
|
||||
if self._get_active_warnings_count() <= self._warning_index:
|
||||
return False
|
||||
|
||||
data = self.coordinator.data[self._region][self._warning_index]
|
||||
|
||||
return data.is_valid
|
||||
return self._get_warning_data().is_valid
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
@@ -99,7 +85,7 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti
|
||||
if not self.is_on:
|
||||
return {}
|
||||
|
||||
data = self.coordinator.data[self._region][self._warning_index]
|
||||
data = self._get_warning_data()
|
||||
|
||||
return {
|
||||
ATTR_HEADLINE: data.headline,
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 (
|
||||
@@ -64,6 +65,12 @@ 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)
|
||||
|
||||
36
homeassistant/components/nina/entity.py
Normal file
36
homeassistant/components/nina/entity.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""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]
|
||||
@@ -45,6 +45,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"warning": {
|
||||
"name": "Warning: {region_name} {slot_id}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"no_fetch": "[%key:component::nina::config::abort::no_fetch%]",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -88,6 +88,9 @@
|
||||
},
|
||||
"charge_set_schedules": {
|
||||
"service": "mdi:calendar-clock"
|
||||
},
|
||||
"charge_start": {
|
||||
"service": "mdi:ev-station"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,9 +165,11 @@ class RenaultVehicleProxy:
|
||||
return await self._vehicle.set_charge_mode(charge_mode)
|
||||
|
||||
@with_error_wrapping
|
||||
async def set_charge_start(self) -> models.KamereonVehicleChargingStartActionData:
|
||||
async def set_charge_start(
|
||||
self, when: datetime | None = None
|
||||
) -> models.KamereonVehicleChargingStartActionData:
|
||||
"""Start vehicle charge."""
|
||||
return await self._vehicle.set_charge_start()
|
||||
return await self._vehicle.set_charge_start(when)
|
||||
|
||||
@with_error_wrapping
|
||||
async def set_charge_stop(self) -> models.KamereonVehicleChargingStartActionData:
|
||||
|
||||
@@ -36,6 +36,11 @@ SERVICE_AC_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
|
||||
vol.Optional(ATTR_WHEN): cv.datetime,
|
||||
}
|
||||
)
|
||||
SERVICE_CHARGE_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(ATTR_WHEN): cv.datetime,
|
||||
}
|
||||
)
|
||||
SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("startTime"): cv.string,
|
||||
@@ -113,6 +118,16 @@ async def ac_start(service_call: ServiceCall) -> None:
|
||||
LOGGER.debug("A/C start result: %s", result.raw_data)
|
||||
|
||||
|
||||
async def charge_start(service_call: ServiceCall) -> None:
|
||||
"""Start Charging with optional delay."""
|
||||
when: datetime | None = service_call.data.get(ATTR_WHEN)
|
||||
proxy = get_vehicle_proxy(service_call)
|
||||
|
||||
LOGGER.debug("Charge start attempt, when: %s", when)
|
||||
result = await proxy.set_charge_start(when)
|
||||
LOGGER.debug("Charge start result: %s", result.raw_data)
|
||||
|
||||
|
||||
async def charge_set_schedules(service_call: ServiceCall) -> None:
|
||||
"""Set charge schedules."""
|
||||
schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
|
||||
@@ -196,6 +211,12 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
ac_start,
|
||||
schema=SERVICE_AC_START_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"charge_start",
|
||||
charge_start,
|
||||
schema=SERVICE_CHARGE_START_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"charge_set_schedules",
|
||||
|
||||
@@ -54,6 +54,18 @@ ac_set_schedules:
|
||||
selector:
|
||||
object:
|
||||
|
||||
charge_start:
|
||||
fields:
|
||||
vehicle:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: renault
|
||||
when:
|
||||
example: "2026-03-01T17:45:00"
|
||||
selector:
|
||||
datetime:
|
||||
|
||||
charge_set_schedules:
|
||||
fields:
|
||||
vehicle:
|
||||
|
||||
@@ -276,6 +276,20 @@
|
||||
}
|
||||
},
|
||||
"name": "Update charge schedule"
|
||||
},
|
||||
"charge_start": {
|
||||
"description": "Starts charging on vehicle.",
|
||||
"fields": {
|
||||
"vehicle": {
|
||||
"description": "[%key:component::renault::services::ac_start::fields::vehicle::description%]",
|
||||
"name": "Vehicle"
|
||||
},
|
||||
"when": {
|
||||
"description": "Timestamp for charging to start (optional - defaults to now).",
|
||||
"name": "When"
|
||||
}
|
||||
},
|
||||
"name": "Start charging"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["sfrbox-api==0.1.0"]
|
||||
"requirements": ["sfrbox-api==0.1.1"]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.const import (
|
||||
CONF_PLATFORM,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import Throttle
|
||||
@@ -94,11 +95,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmappeeConfigEntry) -> b
|
||||
)
|
||||
await hass.async_add_executor_job(smappee.load_local_service_location)
|
||||
else:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
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
|
||||
|
||||
smappee_api = api.ConfigEntrySmappeeApi(hass, entry, implementation)
|
||||
|
||||
|
||||
@@ -43,5 +43,10 @@
|
||||
"title": "Discovered Smappee device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +315,6 @@ async def make_device_data(
|
||||
)
|
||||
devices_data.binary_sensors.append((device, coordinator))
|
||||
devices_data.sensors.append((device, coordinator))
|
||||
|
||||
if isinstance(device, Device) and device.device_type == "AI Art Frame":
|
||||
coordinator = await coordinator_for_device(
|
||||
hass, entry, api, device, coordinators_by_id
|
||||
@@ -323,6 +322,11 @@ async def make_device_data(
|
||||
devices_data.buttons.append((device, coordinator))
|
||||
devices_data.sensors.append((device, coordinator))
|
||||
devices_data.images.append((device, coordinator))
|
||||
if isinstance(device, Device) and device.device_type == "WeatherStation":
|
||||
coordinator = await coordinator_for_device(
|
||||
hass, entry, api, device, coordinators_by_id
|
||||
)
|
||||
devices_data.sensors.append((device, coordinator))
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
@@ -257,6 +257,11 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
|
||||
),
|
||||
"Smart Radiator Thermostat": (BATTERY_DESCRIPTION,),
|
||||
"AI Art Frame": (BATTERY_DESCRIPTION,),
|
||||
"WeatherStation": (
|
||||
BATTERY_DESCRIPTION,
|
||||
TEMPERATURE_DESCRIPTION,
|
||||
HUMIDITY_DESCRIPTION,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/thethingsnetwork",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["ttn_client==1.2.3"]
|
||||
"requirements": ["ttn_client==1.3.0"]
|
||||
}
|
||||
|
||||
17
homeassistant/components/timer/condition.py
Normal file
17
homeassistant/components/timer/condition.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Provides conditions for timers."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
|
||||
from . import DOMAIN, STATUS_ACTIVE, STATUS_IDLE, STATUS_PAUSED
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_active": make_entity_state_condition(DOMAIN, STATUS_ACTIVE),
|
||||
"is_paused": make_entity_state_condition(DOMAIN, STATUS_PAUSED),
|
||||
"is_idle": make_entity_state_condition(DOMAIN, STATUS_IDLE),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the timer conditions."""
|
||||
return CONDITIONS
|
||||
18
homeassistant/components/timer/conditions.yaml
Normal file
18
homeassistant/components/timer/conditions.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
entity:
|
||||
- domain: timer
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_active: *condition_common
|
||||
is_paused: *condition_common
|
||||
is_idle: *condition_common
|
||||
@@ -1,4 +1,15 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_active": {
|
||||
"condition": "mdi:timer"
|
||||
},
|
||||
"is_idle": {
|
||||
"condition": "mdi:timer-off"
|
||||
},
|
||||
"is_paused": {
|
||||
"condition": "mdi:timer-pause"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"cancel": {
|
||||
"service": "mdi:cancel"
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted timers.",
|
||||
"condition_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_active": {
|
||||
"description": "Tests if one or more timers are active.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::timer::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::timer::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Timer is active"
|
||||
},
|
||||
"is_idle": {
|
||||
"description": "Tests if one or more timers are idle.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::timer::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::timer::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Timer is idle"
|
||||
},
|
||||
"is_paused": {
|
||||
"description": "Tests if one or more timers are paused.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::timer::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::timer::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Timer is paused"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "Timer",
|
||||
@@ -30,10 +66,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"cancel": {
|
||||
"description": "Resets a timer's duration to the last known initial value without firing the timer finished event.",
|
||||
"name": "Cancel"
|
||||
"name": "Cancel timer"
|
||||
},
|
||||
"change": {
|
||||
"description": "Changes a timer by adding or subtracting a given duration.",
|
||||
@@ -43,19 +87,19 @@
|
||||
"name": "Duration"
|
||||
}
|
||||
},
|
||||
"name": "Change"
|
||||
"name": "Change timer"
|
||||
},
|
||||
"finish": {
|
||||
"description": "Finishes a running timer earlier than scheduled.",
|
||||
"name": "Finish"
|
||||
"name": "Finish timer"
|
||||
},
|
||||
"pause": {
|
||||
"description": "Pauses a running timer, retaining the remaining duration for later continuation.",
|
||||
"name": "[%key:common::action::pause%]"
|
||||
"name": "Pause timer"
|
||||
},
|
||||
"reload": {
|
||||
"description": "Reloads timers from the YAML-configuration.",
|
||||
"name": "[%key:common::action::reload%]"
|
||||
"name": "Reload timers"
|
||||
},
|
||||
"start": {
|
||||
"description": "Starts a timer or restarts it with a provided duration.",
|
||||
@@ -65,7 +109,7 @@
|
||||
"name": "Duration"
|
||||
}
|
||||
},
|
||||
"name": "[%key:common::action::start%]"
|
||||
"name": "Start timer"
|
||||
}
|
||||
},
|
||||
"title": "Timer"
|
||||
|
||||
@@ -167,146 +167,132 @@ class ItemTriggerBase(Trigger, abc.ABC):
|
||||
"""Handle entities being added/removed from the target."""
|
||||
|
||||
|
||||
class ItemAddedTrigger(ItemTriggerBase):
|
||||
class ItemChangeTriggerBase(ItemTriggerBase):
|
||||
"""todo item change trigger base class."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config: TriggerConfig, description: str
|
||||
) -> None:
|
||||
"""Initialize trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._entity_item_ids: dict[str, set[str] | None] = {}
|
||||
self._description = description
|
||||
|
||||
@abc.abstractmethod
|
||||
def _is_matching_item(self, item: TodoItem) -> bool:
|
||||
"""Return true if the item matches the trigger condition."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_items_diff(
|
||||
self, old_item_ids: set[str], current_item_ids: set[str]
|
||||
) -> set[str]:
|
||||
"""Return the set of item ids that should be reported for this trigger.
|
||||
|
||||
The calculation is based on the previous and current matching item ids.
|
||||
"""
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_item_change(
|
||||
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
|
||||
) -> None:
|
||||
"""Listen for todo item changes."""
|
||||
entity_id = event.entity_id
|
||||
if event.items is None:
|
||||
self._entity_item_ids[entity_id] = None
|
||||
return
|
||||
|
||||
old_item_ids = self._entity_item_ids.get(entity_id)
|
||||
current_item_ids = {
|
||||
item.uid
|
||||
for item in event.items
|
||||
if item.uid is not None and self._is_matching_item(item)
|
||||
}
|
||||
self._entity_item_ids[entity_id] = current_item_ids
|
||||
if old_item_ids is None:
|
||||
# Entity just became available, so no old items to compare against
|
||||
return
|
||||
|
||||
different_item_ids = self._get_items_diff(old_item_ids, current_item_ids)
|
||||
if different_item_ids:
|
||||
_LOGGER.debug(
|
||||
"Detected %s items with ids %s for entity %s",
|
||||
self._description,
|
||||
different_item_ids,
|
||||
entity_id,
|
||||
)
|
||||
payload = {
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"item_ids": sorted(different_item_ids),
|
||||
}
|
||||
run_action(payload, description=f"todo item {self._description} trigger")
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_entities_updated(self, tracked_entities: set[str]) -> None:
|
||||
"""Clear stale state for entities that left the tracked set."""
|
||||
for entity_id in set(self._entity_item_ids) - tracked_entities:
|
||||
del self._entity_item_ids[entity_id]
|
||||
|
||||
|
||||
class ItemAddedTrigger(ItemChangeTriggerBase):
|
||||
"""todo item added trigger."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._entity_item_ids: dict[str, set[str] | None] = {}
|
||||
super().__init__(hass, config, description="added")
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_item_change(
|
||||
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
|
||||
) -> None:
|
||||
"""Listen for todo item changes."""
|
||||
entity_id = event.entity_id
|
||||
if event.items is None:
|
||||
self._entity_item_ids[entity_id] = None
|
||||
return
|
||||
|
||||
old_item_ids = self._entity_item_ids.get(entity_id)
|
||||
current_item_ids = {item.uid for item in event.items if item.uid is not None}
|
||||
self._entity_item_ids[entity_id] = current_item_ids
|
||||
if old_item_ids is None:
|
||||
# Entity just became available, so no old items to compare against
|
||||
return
|
||||
added_item_ids = current_item_ids - old_item_ids
|
||||
if added_item_ids:
|
||||
_LOGGER.debug(
|
||||
"Detected added items with ids %s for entity %s",
|
||||
added_item_ids,
|
||||
entity_id,
|
||||
)
|
||||
payload = {
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"item_ids": sorted(added_item_ids),
|
||||
}
|
||||
run_action(payload, description="todo item added trigger")
|
||||
def _is_matching_item(self, item: TodoItem) -> bool:
|
||||
"""Return true if the item matches the trigger condition."""
|
||||
return True
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_entities_updated(self, tracked_entities: set[str]) -> None:
|
||||
"""Clear stale state for entities that left the tracked set."""
|
||||
for entity_id in set(self._entity_item_ids) - tracked_entities:
|
||||
del self._entity_item_ids[entity_id]
|
||||
def _get_items_diff(
|
||||
self, old_item_ids: set[str], current_item_ids: set[str]
|
||||
) -> set[str]:
|
||||
"""Return the set of item ids that match added items."""
|
||||
return current_item_ids - old_item_ids
|
||||
|
||||
|
||||
class ItemRemovedTrigger(ItemTriggerBase):
|
||||
class ItemRemovedTrigger(ItemChangeTriggerBase):
|
||||
"""todo item removed trigger."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._entity_item_ids: dict[str, set[str] | None] = {}
|
||||
super().__init__(hass, config, description="removed")
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_item_change(
|
||||
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
|
||||
) -> None:
|
||||
"""Listen for todo item changes."""
|
||||
entity_id = event.entity_id
|
||||
if event.items is None:
|
||||
self._entity_item_ids[entity_id] = None
|
||||
return
|
||||
|
||||
old_item_ids = self._entity_item_ids.get(entity_id)
|
||||
current_item_ids = {item.uid for item in event.items if item.uid is not None}
|
||||
self._entity_item_ids[entity_id] = current_item_ids
|
||||
if old_item_ids is None:
|
||||
# Entity just became available, so no old items to compare against
|
||||
return
|
||||
removed_item_ids = old_item_ids - current_item_ids
|
||||
if removed_item_ids:
|
||||
_LOGGER.debug(
|
||||
"Detected removed items with ids %s for entity %s",
|
||||
removed_item_ids,
|
||||
entity_id,
|
||||
)
|
||||
payload = {
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"item_ids": sorted(removed_item_ids),
|
||||
}
|
||||
run_action(payload, description="todo item removed trigger")
|
||||
def _is_matching_item(self, item: TodoItem) -> bool:
|
||||
"""Return true if the item matches the trigger condition."""
|
||||
return True
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_entities_updated(self, tracked_entities: set[str]) -> None:
|
||||
"""Clear stale state for entities that left the tracked set."""
|
||||
for entity_id in set(self._entity_item_ids) - tracked_entities:
|
||||
del self._entity_item_ids[entity_id]
|
||||
def _get_items_diff(
|
||||
self, old_item_ids: set[str], current_item_ids: set[str]
|
||||
) -> set[str]:
|
||||
"""Return the set of item ids that match removed items."""
|
||||
return old_item_ids - current_item_ids
|
||||
|
||||
|
||||
class ItemCompletedTrigger(ItemTriggerBase):
|
||||
class ItemCompletedTrigger(ItemChangeTriggerBase):
|
||||
"""todo item completed trigger."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._entity_completed_item_ids: dict[str, set[str] | None] = {}
|
||||
super().__init__(hass, config, description="completed")
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_item_change(
|
||||
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
|
||||
) -> None:
|
||||
"""Listen for todo item changes."""
|
||||
entity_id = event.entity_id
|
||||
if event.items is None:
|
||||
self._entity_completed_item_ids[entity_id] = None
|
||||
return
|
||||
|
||||
old_item_ids = self._entity_completed_item_ids.get(entity_id)
|
||||
current_item_ids = {
|
||||
item.uid
|
||||
for item in event.items
|
||||
if item.uid is not None and item.status == TodoItemStatus.COMPLETED
|
||||
}
|
||||
self._entity_completed_item_ids[entity_id] = current_item_ids
|
||||
if old_item_ids is None:
|
||||
# Entity just became available, so no old items to compare against
|
||||
return
|
||||
new_completed_item_ids = current_item_ids - old_item_ids
|
||||
if new_completed_item_ids:
|
||||
_LOGGER.debug(
|
||||
"Detected new completed items with ids %s for entity %s",
|
||||
new_completed_item_ids,
|
||||
entity_id,
|
||||
)
|
||||
payload = {
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"item_ids": sorted(new_completed_item_ids),
|
||||
}
|
||||
run_action(payload, description="todo item completed trigger")
|
||||
def _is_matching_item(self, item: TodoItem) -> bool:
|
||||
"""Return true if the item matches the trigger condition."""
|
||||
return item.status == TodoItemStatus.COMPLETED
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_entities_updated(self, tracked_entities: set[str]) -> None:
|
||||
"""Clear stale state for entities that left the tracked set."""
|
||||
for entity_id in set(self._entity_completed_item_ids) - tracked_entities:
|
||||
del self._entity_completed_item_ids[entity_id]
|
||||
def _get_items_diff(
|
||||
self, old_item_ids: set[str], current_item_ids: set[str]
|
||||
) -> set[str]:
|
||||
"""Return the set of item ids that match completed items."""
|
||||
return current_item_ids - old_item_ids
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
|
||||
20
homeassistant/components/valve/condition.py
Normal file
20
homeassistant/components/valve/condition.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Provides conditions for valves."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
|
||||
from . import ATTR_IS_CLOSED
|
||||
from .const import DOMAIN
|
||||
|
||||
VALVE_DOMAIN_SPECS = {DOMAIN: DomainSpec(value_source=ATTR_IS_CLOSED)}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_open": make_entity_state_condition(VALVE_DOMAIN_SPECS, False),
|
||||
"is_closed": make_entity_state_condition(VALVE_DOMAIN_SPECS, True),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the valve conditions."""
|
||||
return CONDITIONS
|
||||
17
homeassistant/components/valve/conditions.yaml
Normal file
17
homeassistant/components/valve/conditions.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
entity:
|
||||
- domain: valve
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_open: *condition_common
|
||||
is_closed: *condition_common
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_closed": {
|
||||
"condition": "mdi:valve-closed"
|
||||
},
|
||||
"is_open": {
|
||||
"condition": "mdi:valve-open"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:valve-open",
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_closed": {
|
||||
"description": "Tests if one or more valves are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "Whether the condition should pass when any or all targeted entities match.",
|
||||
"name": "Behavior"
|
||||
}
|
||||
},
|
||||
"name": "Valve is closed"
|
||||
},
|
||||
"is_open": {
|
||||
"description": "Tests if one or more valves are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "Whether the condition should pass when any or all targeted entities match.",
|
||||
"name": "Behavior"
|
||||
}
|
||||
},
|
||||
"name": "Valve is open"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "[%key:component::valve::title%]",
|
||||
@@ -22,6 +44,14 @@
|
||||
"name": "Water"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"close_valve": {
|
||||
"description": "Closes a valve.",
|
||||
|
||||
@@ -138,6 +138,13 @@ async def async_setup_entry(
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: WaterFurnaceConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a WaterFurnace config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: WaterFurnaceConfigEntry
|
||||
) -> bool:
|
||||
|
||||
@@ -29,7 +29,7 @@ rules:
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not have custom service actions.
|
||||
config-entry-unloading: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
|
||||
@@ -16,8 +16,8 @@ if TYPE_CHECKING:
|
||||
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2026
|
||||
MINOR_VERSION: Final = 4
|
||||
PATCH_VERSION: Final = "0b4"
|
||||
MINOR_VERSION: Final = 5
|
||||
PATCH_VERSION: Final = "0.dev0"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
|
||||
|
||||
@@ -421,7 +421,7 @@ class EntityConditionBase(Condition):
|
||||
class EntityStateConditionBase(EntityConditionBase):
|
||||
"""State condition."""
|
||||
|
||||
_states: set[str]
|
||||
_states: set[str | bool]
|
||||
|
||||
def is_valid_state(self, entity_state: State) -> bool:
|
||||
"""Check if the state matches the expected state(s)."""
|
||||
@@ -439,7 +439,7 @@ def _normalize_domain_specs(
|
||||
|
||||
def make_entity_state_condition(
|
||||
domain_specs: Mapping[str, DomainSpec] | str,
|
||||
states: str | set[str],
|
||||
states: str | bool | set[str | bool],
|
||||
) -> type[EntityStateConditionBase]:
|
||||
"""Create a condition for entity state changes to specific state(s).
|
||||
|
||||
@@ -448,8 +448,8 @@ def make_entity_state_condition(
|
||||
"""
|
||||
specs = _normalize_domain_specs(domain_specs)
|
||||
|
||||
if isinstance(states, str):
|
||||
states_set = {states}
|
||||
if isinstance(states, (str, bool)):
|
||||
states_set: set[str | bool] = {states}
|
||||
else:
|
||||
states_set = states
|
||||
|
||||
|
||||
@@ -868,11 +868,16 @@ def url(
|
||||
) -> str:
|
||||
"""Validate an URL."""
|
||||
url_in = str(value)
|
||||
parsed = urlparse(url_in)
|
||||
|
||||
if urlparse(url_in).scheme in _schema_list:
|
||||
return cast(str, vol.Schema(vol.Url())(url_in))
|
||||
if parsed.scheme not in _schema_list:
|
||||
raise vol.Invalid("invalid url")
|
||||
|
||||
raise vol.Invalid("invalid url")
|
||||
try:
|
||||
_port = parsed.port
|
||||
except ValueError as err:
|
||||
raise vol.Invalid("invalid url") from err
|
||||
return cast(str, vol.Schema(vol.Url())(url_in))
|
||||
|
||||
|
||||
def configuration_url(value: Any) -> str:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2026.4.0b4"
|
||||
version = "2026.5.0.dev0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
|
||||
8
requirements_all.txt
generated
8
requirements_all.txt
generated
@@ -1564,7 +1564,7 @@ mozart-api==5.3.1.108.2
|
||||
mullvad-api==1.0.0
|
||||
|
||||
# homeassistant.components.music_assistant
|
||||
music-assistant-client==1.3.3
|
||||
music-assistant-client==1.3.4
|
||||
|
||||
# homeassistant.components.tts
|
||||
mutagen==1.47.0
|
||||
@@ -2933,7 +2933,7 @@ sentry-sdk==2.48.0
|
||||
serialx==0.6.2
|
||||
|
||||
# homeassistant.components.sfr_box
|
||||
sfrbox-api==0.1.0
|
||||
sfrbox-api==0.1.1
|
||||
|
||||
# homeassistant.components.sharkiq
|
||||
sharkiq==1.5.0
|
||||
@@ -3109,7 +3109,7 @@ thermopro-ble==1.1.3
|
||||
thingspeak==1.0.0
|
||||
|
||||
# homeassistant.components.lg_thinq
|
||||
thinqconnect==1.0.9
|
||||
thinqconnect==1.0.11
|
||||
|
||||
# homeassistant.components.tikteck
|
||||
tikteck==0.4
|
||||
@@ -3154,7 +3154,7 @@ trmnl==0.1.1
|
||||
ttls==1.8.3
|
||||
|
||||
# homeassistant.components.thethingsnetwork
|
||||
ttn_client==1.2.3
|
||||
ttn_client==1.3.0
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-handlers==0.0.15
|
||||
|
||||
8
requirements_test_all.txt
generated
8
requirements_test_all.txt
generated
@@ -1377,7 +1377,7 @@ mozart-api==5.3.1.108.2
|
||||
mullvad-api==1.0.0
|
||||
|
||||
# homeassistant.components.music_assistant
|
||||
music-assistant-client==1.3.3
|
||||
music-assistant-client==1.3.4
|
||||
|
||||
# homeassistant.components.tts
|
||||
mutagen==1.47.0
|
||||
@@ -2490,7 +2490,7 @@ sentry-sdk==2.48.0
|
||||
serialx==0.6.2
|
||||
|
||||
# homeassistant.components.sfr_box
|
||||
sfrbox-api==0.1.0
|
||||
sfrbox-api==0.1.1
|
||||
|
||||
# homeassistant.components.sharkiq
|
||||
sharkiq==1.5.0
|
||||
@@ -2627,7 +2627,7 @@ thermobeacon-ble==0.10.0
|
||||
thermopro-ble==1.1.3
|
||||
|
||||
# homeassistant.components.lg_thinq
|
||||
thinqconnect==1.0.9
|
||||
thinqconnect==1.0.11
|
||||
|
||||
# homeassistant.components.tilt_ble
|
||||
tilt-ble==1.0.1
|
||||
@@ -2666,7 +2666,7 @@ trmnl==0.1.1
|
||||
ttls==1.8.3
|
||||
|
||||
# homeassistant.components.thethingsnetwork
|
||||
ttn_client==1.2.3
|
||||
ttn_client==1.3.0
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-handlers==0.0.15
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .model import Config, Integration
|
||||
from .model import Config, Integration, IntegrationType
|
||||
|
||||
BASE = """
|
||||
# This file is generated by script/hassfest/codeowners.py
|
||||
@@ -65,7 +65,7 @@ def generate_and_validate(integrations: dict[str, Integration], config: Config)
|
||||
for domain in sorted(integrations):
|
||||
integration = integrations[domain]
|
||||
|
||||
if integration.integration_type == "virtual":
|
||||
if integration.integration_type == IntegrationType.VIRTUAL:
|
||||
continue
|
||||
|
||||
codeowners = integration.manifest["codeowners"]
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
from typing import Any
|
||||
|
||||
from .brand import validate as validate_brands
|
||||
from .model import Brand, Config, Integration
|
||||
from .model import Brand, Config, Integration, IntegrationType
|
||||
from .serializer import format_python_namespace
|
||||
|
||||
UNIQUE_ID_IGNORE = {"huawei_lte", "mqtt", "adguard"}
|
||||
@@ -75,7 +75,7 @@ def _generate_and_validate(integrations: dict[str, Integration], config: Config)
|
||||
|
||||
_validate_integration(config, integration)
|
||||
|
||||
if integration.integration_type == "helper":
|
||||
if integration.integration_type == IntegrationType.HELPER:
|
||||
domains["helper"].append(domain)
|
||||
else:
|
||||
domains["integration"].append(domain)
|
||||
@@ -94,8 +94,8 @@ def _populate_brand_integrations(
|
||||
for domain in sub_integrations:
|
||||
integration = integrations.get(domain)
|
||||
if not integration or integration.integration_type in (
|
||||
"entity",
|
||||
"system",
|
||||
IntegrationType.ENTITY,
|
||||
IntegrationType.SYSTEM,
|
||||
):
|
||||
continue
|
||||
metadata: dict[str, Any] = {
|
||||
@@ -170,7 +170,10 @@ def _generate_integrations(
|
||||
result["integration"][domain] = metadata
|
||||
else: # integration
|
||||
integration = integrations[domain]
|
||||
if integration.integration_type in ("entity", "system"):
|
||||
if integration.integration_type in (
|
||||
IntegrationType.ENTITY,
|
||||
IntegrationType.SYSTEM,
|
||||
):
|
||||
continue
|
||||
|
||||
if integration.translated_name:
|
||||
@@ -180,7 +183,7 @@ def _generate_integrations(
|
||||
|
||||
metadata["integration_type"] = integration.integration_type
|
||||
|
||||
if integration.integration_type == "virtual":
|
||||
if integration.integration_type == IntegrationType.VIRTUAL:
|
||||
if integration.supported_by:
|
||||
metadata["supported_by"] = integration.supported_by
|
||||
if integration.iot_standards:
|
||||
@@ -195,7 +198,7 @@ def _generate_integrations(
|
||||
):
|
||||
metadata["single_config_entry"] = single_config_entry
|
||||
|
||||
if integration.integration_type == "helper":
|
||||
if integration.integration_type == IntegrationType.HELPER:
|
||||
result["helper"][domain] = metadata
|
||||
else:
|
||||
result["integration"][domain] = metadata
|
||||
|
||||
@@ -4,7 +4,7 @@ import re
|
||||
|
||||
from homeassistant.util.yaml import load_yaml_dict
|
||||
|
||||
from .model import Config, Integration
|
||||
from .model import Config, Integration, IntegrationType
|
||||
|
||||
# Non-entity-platform components that belong in base_platforms
|
||||
EXTRA_BASE_PLATFORMS = {"diagnostics"}
|
||||
@@ -29,7 +29,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
entity_platforms = {
|
||||
integration.domain
|
||||
for integration in integrations.values()
|
||||
if integration.manifest.get("integration_type") == "entity"
|
||||
if integration.integration_type == IntegrationType.ENTITY
|
||||
and integration.domain != "tag"
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from voluptuous.humanize import humanize_error
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.icon import convert_shorthand_service_icon
|
||||
|
||||
from .model import Config, Integration
|
||||
from .model import Config, Integration, IntegrationType
|
||||
from .translations import translation_key_validator
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys(
|
||||
|
||||
|
||||
def icon_schema(
|
||||
core_integration: bool, integration_type: str, no_entity_platform: bool
|
||||
core_integration: bool, integration_type: IntegrationType, no_entity_platform: bool
|
||||
) -> vol.Schema:
|
||||
"""Create an icon schema."""
|
||||
|
||||
@@ -189,8 +189,12 @@ def icon_schema(
|
||||
}
|
||||
)
|
||||
|
||||
if integration_type in ("entity", "helper", "system"):
|
||||
if integration_type != "entity" or no_entity_platform:
|
||||
if integration_type in (
|
||||
IntegrationType.ENTITY,
|
||||
IntegrationType.HELPER,
|
||||
IntegrationType.SYSTEM,
|
||||
):
|
||||
if integration_type != IntegrationType.ENTITY or no_entity_platform:
|
||||
field = vol.Optional("entity_component")
|
||||
else:
|
||||
field = vol.Required("entity_component")
|
||||
@@ -207,7 +211,7 @@ def icon_schema(
|
||||
)
|
||||
}
|
||||
)
|
||||
if integration_type not in ("entity", "system"):
|
||||
if integration_type not in (IntegrationType.ENTITY, IntegrationType.SYSTEM):
|
||||
schema = schema.extend(
|
||||
{
|
||||
vol.Optional("entity"): vol.All(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .model import Config, Integration
|
||||
from .model import Config, Integration, IntegrationType
|
||||
from .serializer import format_python
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
if config.specific_integrations:
|
||||
return
|
||||
|
||||
int_type = "entity"
|
||||
int_type = IntegrationType.ENTITY
|
||||
|
||||
domains = [
|
||||
integration.domain
|
||||
for integration in integrations.values()
|
||||
if integration.manifest.get("integration_type") == int_type
|
||||
if integration.integration_type == int_type
|
||||
# Tag is type "entity" but has no entity platform
|
||||
and integration.domain != "tag"
|
||||
]
|
||||
@@ -36,7 +36,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
|
||||
def generate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
"""Generate integration file."""
|
||||
int_type = "entity"
|
||||
int_type = IntegrationType.ENTITY
|
||||
filename = "entity_platforms"
|
||||
platform_path = config.root / f"homeassistant/generated/{filename}.py"
|
||||
platform_path.write_text(config.cache[f"integrations_{int_type}"])
|
||||
|
||||
@@ -21,7 +21,7 @@ from homeassistant.const import Platform
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from script.util import sort_manifest as util_sort_manifest
|
||||
|
||||
from .model import Config, Integration, ScaledQualityScaleTiers
|
||||
from .model import Config, Integration, IntegrationType, ScaledQualityScaleTiers
|
||||
|
||||
DOCUMENTATION_URL_SCHEMA = "https"
|
||||
DOCUMENTATION_URL_HOST = "www.home-assistant.io"
|
||||
@@ -206,15 +206,7 @@ INTEGRATION_MANIFEST_SCHEMA = vol.Schema(
|
||||
vol.Required("domain"): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Optional("integration_type", default="hub"): vol.In(
|
||||
[
|
||||
"device",
|
||||
"entity",
|
||||
"hardware",
|
||||
"helper",
|
||||
"hub",
|
||||
"service",
|
||||
"system",
|
||||
]
|
||||
[t.value for t in IntegrationType if t != IntegrationType.VIRTUAL]
|
||||
),
|
||||
vol.Optional("config_flow"): bool,
|
||||
vol.Optional("mqtt"): [str],
|
||||
@@ -311,7 +303,7 @@ VIRTUAL_INTEGRATION_MANIFEST_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("domain"): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Required("integration_type"): "virtual",
|
||||
vol.Required("integration_type"): IntegrationType.VIRTUAL.value,
|
||||
vol.Exclusive("iot_standards", "virtual_integration"): [
|
||||
vol.Any("homekit", "zigbee", "zwave")
|
||||
],
|
||||
@@ -322,7 +314,7 @@ VIRTUAL_INTEGRATION_MANIFEST_SCHEMA = vol.Schema(
|
||||
|
||||
def manifest_schema(value: dict[str, Any]) -> vol.Schema:
|
||||
"""Validate integration manifest."""
|
||||
if value.get("integration_type") == "virtual":
|
||||
if value.get("integration_type") == IntegrationType.VIRTUAL:
|
||||
return VIRTUAL_INTEGRATION_MANIFEST_SCHEMA(value)
|
||||
return INTEGRATION_MANIFEST_SCHEMA(value)
|
||||
|
||||
@@ -373,12 +365,12 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No
|
||||
if (
|
||||
domain not in NO_IOT_CLASS
|
||||
and "iot_class" not in integration.manifest
|
||||
and integration.manifest.get("integration_type") != "virtual"
|
||||
and integration.integration_type != IntegrationType.VIRTUAL
|
||||
):
|
||||
integration.add_error("manifest", "Domain is missing an IoT Class")
|
||||
|
||||
if (
|
||||
integration.manifest.get("integration_type") == "virtual"
|
||||
integration.integration_type == IntegrationType.VIRTUAL
|
||||
and (supported_by := integration.manifest.get("supported_by"))
|
||||
and not (core_components_dir / supported_by).exists()
|
||||
):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from enum import IntEnum, StrEnum
|
||||
import json
|
||||
import pathlib
|
||||
from typing import Any, Literal
|
||||
@@ -200,9 +200,15 @@ class Integration:
|
||||
return self.manifest.get("supported_by", {})
|
||||
|
||||
@property
|
||||
def integration_type(self) -> str:
|
||||
def integration_type(self) -> IntegrationType:
|
||||
"""Get integration_type."""
|
||||
return self.manifest.get("integration_type", "hub")
|
||||
integration_type = self.manifest.get("integration_type", "hub")
|
||||
try:
|
||||
return IntegrationType(integration_type)
|
||||
except ValueError:
|
||||
# The manifest validation will catch this as an error, so we can default to
|
||||
# a valid value here to avoid ValueErrors in other plugins
|
||||
return IntegrationType.HUB
|
||||
|
||||
@property
|
||||
def iot_class(self) -> str | None:
|
||||
@@ -248,6 +254,19 @@ class Integration:
|
||||
self.manifest_path = manifest_path
|
||||
|
||||
|
||||
class IntegrationType(StrEnum):
|
||||
"""Supported integration types."""
|
||||
|
||||
DEVICE = "device"
|
||||
ENTITY = "entity"
|
||||
HARDWARE = "hardware"
|
||||
HELPER = "helper"
|
||||
HUB = "hub"
|
||||
SERVICE = "service"
|
||||
SYSTEM = "system"
|
||||
VIRTUAL = "virtual"
|
||||
|
||||
|
||||
class ScaledQualityScaleTiers(IntEnum):
|
||||
"""Supported manifest quality scales."""
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.const import Platform
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util.yaml import load_yaml_dict
|
||||
|
||||
from .model import Config, Integration, ScaledQualityScaleTiers
|
||||
from .model import Config, Integration, IntegrationType, ScaledQualityScaleTiers
|
||||
from .quality_scale_validation import (
|
||||
RuleValidationProtocol,
|
||||
action_setup,
|
||||
@@ -2200,7 +2200,7 @@ def validate_iqs_file(config: Config, integration: Integration) -> None:
|
||||
if (
|
||||
integration.domain not in INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE
|
||||
and integration.domain not in NO_QUALITY_SCALE
|
||||
and integration.integration_type != "virtual"
|
||||
and integration.integration_type != IntegrationType.VIRTUAL
|
||||
):
|
||||
integration.add_error(
|
||||
"quality_scale",
|
||||
@@ -2218,7 +2218,7 @@ def validate_iqs_file(config: Config, integration: Integration) -> None:
|
||||
)
|
||||
return
|
||||
return
|
||||
if integration.integration_type == "virtual":
|
||||
if integration.integration_type == IntegrationType.VIRTUAL:
|
||||
integration.add_error(
|
||||
"quality_scale",
|
||||
"Virtual integrations are not allowed to have a quality scale file.",
|
||||
|
||||
@@ -14,7 +14,7 @@ from voluptuous.humanize import humanize_error
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from script.translations import upload
|
||||
|
||||
from .model import Config, Integration
|
||||
from .model import Config, Integration, IntegrationType
|
||||
|
||||
UNDEFINED = 0
|
||||
REQUIRED = 1
|
||||
@@ -345,7 +345,9 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
|
||||
flow_title=REMOVED,
|
||||
require_step_title=False,
|
||||
mandatory_description=(
|
||||
"user" if integration.integration_type == "helper" else None
|
||||
"user"
|
||||
if integration.integration_type == IntegrationType.HELPER
|
||||
else None
|
||||
),
|
||||
),
|
||||
vol.Optional("config_subentries"): cv.schema_with_slug_keys(
|
||||
|
||||
@@ -12,12 +12,32 @@ from homeassistant.components.aladdin_connect import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from . import init_integration
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
async def test_oauth_implementation_not_available(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
|
||||
side_effect=ImplementationUnavailableError,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_entry(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""Tests for the arcam_fmj component."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import Mock, patch
|
||||
from asyncio import CancelledError, Queue
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from arcam.fmj.client import Client
|
||||
from arcam.fmj.client import Client, ResponsePacket
|
||||
from arcam.fmj.state import State
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.arcam_fmj.const import DEFAULT_NAME
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
@@ -28,12 +30,50 @@ MOCK_CONFIG_ENTRY = {CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def client_fixture() -> Mock:
|
||||
def client_fixture() -> Generator[Mock]:
|
||||
"""Get a mocked client."""
|
||||
client = Mock(Client)
|
||||
client.host = MOCK_HOST
|
||||
client.port = MOCK_PORT
|
||||
return client
|
||||
|
||||
queue = Queue[BaseException | None]()
|
||||
listeners = set()
|
||||
|
||||
async def _start():
|
||||
client.connected = True
|
||||
|
||||
async def _process():
|
||||
result = await queue.get()
|
||||
client.connected = False
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
|
||||
@contextmanager
|
||||
def _listen(listener):
|
||||
listeners.add(listener)
|
||||
yield client
|
||||
listeners.remove(listener)
|
||||
|
||||
@callback
|
||||
def _notify_data_updated(zn=1):
|
||||
packet = Mock(ResponsePacket)
|
||||
packet.zn = zn
|
||||
for listener in listeners:
|
||||
listener(packet)
|
||||
|
||||
@callback
|
||||
def _notify_connection(exception: Exception | None = None):
|
||||
queue.put_nowait(exception)
|
||||
|
||||
client.start.side_effect = _start
|
||||
client.process.side_effect = _process
|
||||
client.listen.side_effect = _listen
|
||||
client.notify_data_updated = _notify_data_updated
|
||||
client.notify_connection = _notify_connection
|
||||
|
||||
yield client
|
||||
|
||||
queue.put_nowait(CancelledError())
|
||||
|
||||
|
||||
@pytest.fixture(name="state_1")
|
||||
@@ -52,6 +92,8 @@ def state_1_fixture(client: Mock) -> State:
|
||||
state.get_mute.return_value = None
|
||||
state.get_decode_modes.return_value = []
|
||||
state.get_decode_mode.return_value = None
|
||||
state.__aenter__ = AsyncMock()
|
||||
state.__aexit__ = AsyncMock()
|
||||
return state
|
||||
|
||||
|
||||
@@ -71,6 +113,8 @@ def state_2_fixture(client: Mock) -> State:
|
||||
state.get_mute.return_value = None
|
||||
state.get_decode_modes.return_value = []
|
||||
state.get_decode_mode.return_value = None
|
||||
state.__aenter__ = AsyncMock()
|
||||
state.__aexit__ = AsyncMock()
|
||||
return state
|
||||
|
||||
|
||||
@@ -104,18 +148,6 @@ async def player_setup_fixture(
|
||||
return state_2
|
||||
raise ValueError(f"Unknown player zone: {zone}")
|
||||
|
||||
async def _mock_run_client(hass: HomeAssistant, runtime_data, interval):
|
||||
coordinators = runtime_data.coordinators
|
||||
|
||||
def _notify_data_updated() -> None:
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_data_updated()
|
||||
|
||||
client.notify_data_updated = _notify_data_updated
|
||||
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_connected()
|
||||
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
with (
|
||||
@@ -124,10 +156,6 @@ async def player_setup_fixture(
|
||||
"homeassistant.components.arcam_fmj.coordinator.State",
|
||||
side_effect=state_mock,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.arcam_fmj._run_client",
|
||||
side_effect=_mock_run_client,
|
||||
),
|
||||
):
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -33,7 +33,7 @@ from homeassistant.components.media_player import (
|
||||
SERVICE_VOLUME_UP,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant, State as CoreState
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -62,6 +62,21 @@ async def test_setup(
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("player_setup")
|
||||
async def test_disconnect(hass: HomeAssistant, client: Mock) -> None:
|
||||
"""Test a disconnection is detected."""
|
||||
data = hass.states.get(MOCK_ENTITY_ID)
|
||||
assert data
|
||||
assert data.state != STATE_UNAVAILABLE
|
||||
|
||||
client.notify_connection(ConnectionFailed())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
data = hass.states.get(MOCK_ENTITY_ID)
|
||||
assert data
|
||||
assert data.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def update(hass: HomeAssistant, client: Mock, entity_id: str) -> CoreState:
|
||||
"""Force a update of player and return current state data."""
|
||||
client.notify_data_updated()
|
||||
|
||||
@@ -425,6 +425,7 @@ async def test_well_known_auth_info(
|
||||
"authorization_endpoint": f"{expected_url_prefix}/auth/authorize",
|
||||
"token_endpoint": f"{expected_url_prefix}/auth/token",
|
||||
"revocation_endpoint": f"{expected_url_prefix}/auth/revoke",
|
||||
"client_id_metadata_document_supported": True,
|
||||
"response_types_supported": ["code"],
|
||||
"service_documentation": "https://developers.home-assistant.io/docs/auth_api",
|
||||
}
|
||||
|
||||
114
tests/components/calendar/test_condition.py
Normal file
114
tests/components/calendar/test_condition.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Test calendar conditions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_calendars(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple calendar entities associated with different targets."""
|
||||
return await target_entities(hass, "calendar")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"condition",
|
||||
[
|
||||
"calendar.is_event_active",
|
||||
],
|
||||
)
|
||||
async def test_calendar_conditions_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
|
||||
) -> None:
|
||||
"""Test the calendar conditions are gated by the labs flag."""
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("calendar"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states_any(
|
||||
condition="calendar.is_event_active",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_calendar_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_calendars: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test calendar condition with 'any' behavior."""
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_calendars,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("calendar"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states_all(
|
||||
condition="calendar.is_event_active",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_calendar_condition_behavior_all(
|
||||
hass: HomeAssistant,
|
||||
target_calendars: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test calendar condition with 'all' behavior."""
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_calendars,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -12,6 +13,9 @@ from homeassistant.components.fitbit.const import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from .conftest import (
|
||||
CLIENT_ID,
|
||||
@@ -26,6 +30,23 @@ from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
|
||||
async def test_oauth_implementation_not_available(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
|
||||
side_effect=ImplementationUnavailableError,
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup(
|
||||
hass: HomeAssistant,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
|
||||
@@ -40,6 +40,7 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
domain=DOMAIN,
|
||||
data={CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass"},
|
||||
unique_id="test-user",
|
||||
entry_id="01JKRA6QKPBE00ZZ9BKWDB3CTB",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
"""Test the Fresh-r initialization."""
|
||||
|
||||
from aiohttp import ClientError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from pyfreshr.exceptions import ApiResponseError, LoginError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.freshr.const import DOMAIN
|
||||
from homeassistant.components.freshr.coordinator import (
|
||||
DEVICES_SCAN_INTERVAL,
|
||||
READINGS_SCAN_INTERVAL,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from .conftest import MagicMock, MockConfigEntry
|
||||
from .conftest import DEVICE_ID, MagicMock, MockConfigEntry
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
@@ -64,3 +72,47 @@ async def test_setup_no_devices(
|
||||
er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_stale_device_removed(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_freshr_client: MagicMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that a device absent from a successful poll is removed from the registry."""
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)})
|
||||
|
||||
mock_freshr_client.fetch_devices.return_value = []
|
||||
freezer.tick(DEVICES_SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) is None
|
||||
|
||||
call_count = mock_freshr_client.fetch_device_current.call_count
|
||||
freezer.tick(READINGS_SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_freshr_client.fetch_device_current.call_count == call_count
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_stale_device_not_removed_on_poll_error(
|
||||
hass: HomeAssistant,
|
||||
mock_freshr_client: MagicMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that a device is not removed when the devices poll fails."""
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)})
|
||||
|
||||
mock_freshr_client.fetch_devices.side_effect = ApiResponseError("cloud error")
|
||||
freezer.tick(DEVICES_SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)})
|
||||
|
||||
@@ -5,16 +5,19 @@ from unittest.mock import MagicMock
|
||||
from aiohttp import ClientError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from pyfreshr.exceptions import ApiResponseError
|
||||
from pyfreshr.models import DeviceReadings
|
||||
from pyfreshr.models import DeviceReadings, DeviceSummary
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.freshr.const import DOMAIN
|
||||
from homeassistant.components.freshr.coordinator import READINGS_SCAN_INTERVAL
|
||||
from homeassistant.components.freshr.coordinator import (
|
||||
DEVICES_SCAN_INTERVAL,
|
||||
READINGS_SCAN_INTERVAL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from .conftest import DEVICE_ID
|
||||
from .conftest import DEVICE_ID, MOCK_DEVICE_CURRENT
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
@@ -82,3 +85,71 @@ async def test_readings_connection_error_makes_unavailable(
|
||||
state = hass.states.get("sensor.fresh_r_inside_temperature")
|
||||
assert state is not None
|
||||
assert state.state == "unavailable"
|
||||
|
||||
|
||||
DEVICE_ID_2 = "SN002"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_device_reappears_after_removal(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_freshr_client: MagicMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that entities are re-created when a previously removed device reappears."""
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)})
|
||||
|
||||
# Device disappears from the account
|
||||
mock_freshr_client.fetch_devices.return_value = []
|
||||
freezer.tick(DEVICES_SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) is None
|
||||
|
||||
# Device reappears
|
||||
mock_freshr_client.fetch_devices.return_value = [DeviceSummary(id=DEVICE_ID)]
|
||||
mock_freshr_client.fetch_device_current.return_value = MOCK_DEVICE_CURRENT
|
||||
freezer.tick(DEVICES_SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)})
|
||||
t1_entity_id = entity_registry.async_get_entity_id(
|
||||
"sensor", DOMAIN, f"{DEVICE_ID}_t1"
|
||||
)
|
||||
assert t1_entity_id
|
||||
assert hass.states.get(t1_entity_id).state != "unavailable"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_dynamic_device_added(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_freshr_client: MagicMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that sensors are created for a device that appears after initial setup."""
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID_2)}) is None
|
||||
|
||||
mock_freshr_client.fetch_devices.return_value = [
|
||||
DeviceSummary(id=DEVICE_ID),
|
||||
DeviceSummary(id=DEVICE_ID_2),
|
||||
]
|
||||
mock_freshr_client.fetch_device_current.return_value = MOCK_DEVICE_CURRENT
|
||||
freezer.tick(DEVICES_SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID_2)})
|
||||
t1_entity_id = entity_registry.async_get_entity_id(
|
||||
"sensor", DOMAIN, f"{DEVICE_ID_2}_t1"
|
||||
)
|
||||
assert t1_entity_id
|
||||
assert entity_registry.async_get_entity_id("sensor", DOMAIN, f"{DEVICE_ID_2}_co2")
|
||||
assert hass.states.get(t1_entity_id).state != "unavailable"
|
||||
|
||||
@@ -21,6 +21,9 @@ from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.dt import UTC, utcnow
|
||||
|
||||
@@ -902,3 +905,20 @@ async def test_remove_entry(
|
||||
|
||||
assert await hass.config_entries.async_remove(entry.entry_id)
|
||||
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_oauth_implementation_not_available(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
|
||||
side_effect=ImplementationUnavailableError,
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import http
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from aiohttp import ClientError
|
||||
from google_photos_library_api.exceptions import GooglePhotosApiError
|
||||
@@ -10,6 +11,7 @@ import pytest
|
||||
from homeassistant.components.google_photos.const import OAUTH2_TOKEN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
@@ -118,3 +120,19 @@ async def test_coordinator_init_failure(
|
||||
) -> None:
|
||||
"""Test init failure to load albums."""
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_entry_implementation_unavailable(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setup entry when implementation is unavailable."""
|
||||
with patch(
|
||||
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
|
||||
side_effect=config_entry_oauth2_flow.ImplementationUnavailableError,
|
||||
):
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user