Compare commits

..

18 Commits

Author SHA1 Message Date
Franck Nijhof 635669278c Merge branch 'dev' into manual_trigger_entity-fix-availability 2025-03-02 21:03:23 +01:00
G Johansson 1c5eb92c9c Clean test 2025-01-13 18:26:36 +00:00
G Johansson 3337dd4ed7 Add state template test 2025-01-13 18:21:31 +00:00
G Johansson f1d21685e6 Remove availability from complex 2025-01-13 17:59:02 +00:00
G Johansson 73f27549e4 Error better name 2025-01-13 17:58:40 +00:00
G Johansson 1882b914dc render templates calls availability render 2025-01-13 17:54:19 +00:00
G Johansson 06f99dc9ba Mods 2025-01-07 23:07:03 +00:00
G Johansson 2e2c718d94 Remove render complex for availability 2025-01-07 21:18:12 +00:00
G Johansson b8f56a6ed6 Fix 2025-01-07 21:18:12 +00:00
G Johansson db37dbec03 Fix 2025-01-07 21:18:12 +00:00
G Johansson 579f44468e Only render templates if availability is true 2025-01-07 21:18:12 +00:00
G Johansson d452e957c9 Last fix 2025-01-07 21:18:12 +00:00
G Johansson 5f9bcd583b Mod 2025-01-07 21:18:12 +00:00
G Johansson c0c508c7a2 Fix 2025-01-07 21:18:11 +00:00
Erik 13f5adfa84 Try a different approach 2025-01-07 21:18:11 +00:00
G Johansson a07a3a61bf Add test 2025-01-07 21:18:11 +00:00
G Johansson 848162debd Fix when state breaks to stringify 2025-01-07 21:18:11 +00:00
G Johansson 07cd669bc1 Fix availability for manual trigger entities 2025-01-07 21:18:11 +00:00
1494 changed files with 17062 additions and 73036 deletions
+14 -14
View File
@@ -32,7 +32,7 @@ jobs:
fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -69,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: translations
path: translations.tar.gz
@@ -116,7 +116,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations
uses: actions/download-artifact@v4.2.1
uses: actions/download-artifact@v4.1.9
with:
name: translations
@@ -190,14 +190,14 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.4.0
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@2025.03.0
uses: home-assistant/builder@2025.02.0
with:
args: |
$BUILD_ARGS \
@@ -256,14 +256,14 @@ jobs:
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.4.0
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@2025.03.0
uses: home-assistant/builder@2025.02.0
with:
args: |
$BUILD_ARGS \
@@ -330,14 +330,14 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@v3.4.0
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant'
uses: docker/login-action@v3.4.0
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -457,12 +457,12 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@v4.2.1
uses: actions/download-artifact@v4.1.9
with:
name: translations
@@ -502,7 +502,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -531,7 +531,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
uses: actions/attest-build-provenance@bd77c077858b8d561b7a36cbe48ef4cc642ca39d # v2.2.2
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
+78 -147
View File
@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 12
CACHE_VERSION: 11
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 9
HA_SHORT_VERSION: "2025.4"
@@ -89,7 +89,6 @@ jobs:
test_groups: ${{ steps.info.outputs.test_groups }}
tests_glob: ${{ steps.info.outputs.tests_glob }}
tests: ${{ steps.info.outputs.tests }}
lint_only: ${{ steps.info.outputs.lint_only }}
skip_coverage: ${{ steps.info.outputs.skip_coverage }}
runs-on: ubuntu-24.04
steps:
@@ -143,7 +142,6 @@ jobs:
test_group_count=10
tests="[]"
tests_glob=""
lint_only=""
skip_coverage=""
if [[ "${{ steps.integrations.outputs.changes }}" != "[]" ]];
@@ -194,17 +192,6 @@ jobs:
test_full_suite="true"
fi
if [[ "${{ github.event.inputs.lint-only }}" == "true" ]] \
|| [[ "${{ github.event.inputs.pylint-only }}" == "true" ]] \
|| [[ "${{ github.event.inputs.mypy-only }}" == "true" ]] \
|| [[ "${{ github.event.inputs.audit-licenses-only }}" == "true" ]] \
|| [[ "${{ github.event_name }}" == "push" \
&& "${{ github.event.repository.full_name }}" != "home-assistant/core" ]];
then
lint_only="true"
skip_coverage="true"
fi
if [[ "${{ github.event.inputs.skip-coverage }}" == "true" ]] \
|| [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-skip-coverage') }}" == "true" ]];
then
@@ -230,8 +217,6 @@ jobs:
echo "tests=${tests}" >> $GITHUB_OUTPUT
echo "tests_glob: ${tests_glob}"
echo "tests_glob=${tests_glob}" >> $GITHUB_OUTPUT
echo "lint_only": ${lint_only}
echo "lint_only=${lint_only}" >> $GITHUB_OUTPUT
echo "skip_coverage: ${skip_coverage}"
echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT
@@ -249,13 +234,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: venv
key: >-
@@ -271,7 +256,7 @@ jobs:
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
@@ -294,14 +279,14 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -310,7 +295,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -334,14 +319,14 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -350,7 +335,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -374,14 +359,14 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -390,7 +375,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -484,7 +469,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -497,7 +482,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: venv
key: >-
@@ -505,7 +490,7 @@ jobs:
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -552,7 +537,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -587,13 +572,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -620,13 +605,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -638,25 +623,6 @@ jobs:
. venv/bin/activate
python -m script.gen_requirements_all validate
dependency-review:
name: Dependency review
runs-on: ubuntu-24.04
needs:
- info
- base
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
&& needs.info.outputs.requirements == 'true'
&& github.event_name == 'pull_request'
steps:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Dependency review
uses: actions/dependency-review-action@v4.5.0
with:
license-check: false # We use our own license audit checks
audit-licenses:
name: Audit licenses
runs-on: ubuntu-24.04
@@ -677,13 +643,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -695,7 +661,7 @@ jobs:
. venv/bin/activate
python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json
- name: Upload licenses
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
path: licenses-${{ matrix.python-version }}.json
@@ -720,13 +686,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -767,13 +733,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -812,7 +778,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -825,7 +791,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -833,7 +799,7 @@ jobs:
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: .mypy_cache
key: >-
@@ -863,7 +829,11 @@ jobs:
prepare-pytest-full:
runs-on: ubuntu-24.04
if: |
needs.info.outputs.lint_only != 'true'
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
&& github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.test_full_suite == 'true'
needs:
- info
@@ -889,13 +859,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -907,7 +877,7 @@ jobs:
. venv/bin/activate
python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests
- name: Upload pytest_buckets
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: pytest_buckets
path: pytest_buckets.txt
@@ -916,7 +886,11 @@ jobs:
pytest-full:
runs-on: ubuntu-24.04
if: |
needs.info.outputs.lint_only != 'true'
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
&& github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.test_full_suite == 'true'
needs:
- info
@@ -949,13 +923,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -968,7 +942,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: actions/download-artifact@v4.2.1
uses: actions/download-artifact@v4.1.9
with:
name: pytest_buckets
- name: Compile English translations
@@ -988,7 +962,6 @@ jobs:
if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then
cov_params+=(--cov="homeassistant")
cov_params+=(--cov-report=xml)
cov_params+=(--junitxml=junit.xml -o junit_family=legacy)
fi
echo "Test group ${{ matrix.group }}: $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt)"
@@ -1007,24 +980,18 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-full.conclusion == 'failure'
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
overwrite: true
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@v4.6.2
with:
name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }}
path: junit.xml
- name: Remove pytest_buckets
run: rm pytest_buckets.txt
- name: Check dirty
@@ -1042,7 +1009,11 @@ jobs:
MYSQL_ROOT_PASSWORD: password
options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3
if: |
needs.info.outputs.lint_only != 'true'
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
&& github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.mariadb_groups != '[]'
needs:
- info
@@ -1074,13 +1045,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -1117,7 +1088,6 @@ jobs:
cov_params+=(--cov="homeassistant.components.recorder")
cov_params+=(--cov-report=xml)
cov_params+=(--cov-report=term-missing)
cov_params+=(--junitxml=junit.xml -o junit_family=legacy)
fi
python3 -b -X dev -m pytest \
@@ -1138,7 +1108,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1146,19 +1116,12 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
path: coverage.xml
overwrite: true
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@v4.6.2
with:
name: test-results-mariadb-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
path: junit.xml
- name: Check dirty
run: |
./script/check_dirty
@@ -1174,7 +1137,11 @@ jobs:
POSTGRES_PASSWORD: password
options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3
if: |
needs.info.outputs.lint_only != 'true'
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
&& github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.postgresql_groups != '[]'
needs:
- info
@@ -1208,13 +1175,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -1251,7 +1218,6 @@ jobs:
cov_params+=(--cov="homeassistant.components.recorder")
cov_params+=(--cov-report=xml)
cov_params+=(--cov-report=term-missing)
cov_params+=(--junitxml=junit.xml -o junit_family=legacy)
fi
python3 -b -X dev -m pytest \
@@ -1273,7 +1239,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1281,19 +1247,12 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
path: coverage.xml
overwrite: true
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@v4.6.2
with:
name: test-results-postgres-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
path: junit.xml
- name: Check dirty
run: |
./script/check_dirty
@@ -1312,7 +1271,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Download all coverage artifacts
uses: actions/download-artifact@v4.2.1
uses: actions/download-artifact@v4.1.9
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1326,7 +1285,11 @@ jobs:
pytest-partial:
runs-on: ubuntu-24.04
if: |
needs.info.outputs.lint_only != 'true'
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
&& github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.tests_glob
&& needs.info.outputs.test_full_suite == 'false'
needs:
@@ -1359,13 +1322,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.3
uses: actions/cache/restore@v4.2.2
with:
path: venv
fail-on-cache-miss: true
@@ -1402,7 +1365,6 @@ jobs:
cov_params+=(--cov="homeassistant.components.${{ matrix.group }}")
cov_params+=(--cov-report=xml)
cov_params+=(--cov-report=term-missing)
cov_params+=(--junitxml=junit.xml -o junit_family=legacy)
fi
python3 -b -X dev -m pytest \
@@ -1420,24 +1382,18 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
overwrite: true
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@v4.6.2
with:
name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }}
path: junit.xml
- name: Check dirty
run: |
./script/check_dirty
@@ -1454,7 +1410,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Download all coverage artifacts
uses: actions/download-artifact@v4.2.1
uses: actions/download-artifact@v4.1.9
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1463,28 +1419,3 @@ jobs:
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
upload-test-results:
name: Upload test results to Codecov
# codecov/test-results-action currently doesn't support tokenless uploads
# therefore we can't run it on forks
if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.info.outputs.skip_coverage != 'true' && !cancelled() }}
runs-on: ubuntu-24.04
needs:
- info
- pytest-partial
- pytest-full
- pytest-postgres
- pytest-mariadb
timeout-minutes: 10
steps:
- name: Download all coverage artifacts
uses: actions/download-artifact@v4.2.1
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/test-results-action@v1
with:
fail_ci_if_error: true
verbose: true
token: ${{ secrets.CODECOV_TOKEN }}
+2 -2
View File
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.13
uses: github/codeql-action/init@v3.28.10
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.13
uses: github/codeql-action/analyze@v3.28.10
with:
category: "/language:python"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
+14 -14
View File
@@ -36,7 +36,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.5.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -91,7 +91,7 @@ jobs:
) > build_constraints.txt
- name: Upload env_file
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: env_file
path: ./.env_file
@@ -99,14 +99,14 @@ jobs:
overwrite: true
- name: Upload build_constraints
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: build_constraints
path: ./build_constraints.txt
overwrite: true
- name: Upload requirements_diff
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: requirements_diff
path: ./requirements_diff.txt
@@ -118,7 +118,7 @@ jobs:
python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt
@@ -138,17 +138,17 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Download env_file
uses: actions/download-artifact@v4.2.1
uses: actions/download-artifact@v4.1.9
with:
name: env_file
- name: Download build_constraints
uses: actions/download-artifact@v4.2.1
uses: actions/download-artifact@v4.1.9
with:
name: build_constraints
- name: Download requirements_diff
uses: actions/download-artifact@v4.2.1
uses: actions/download-artifact@v4.1.9
with:
name: requirements_diff
@@ -159,7 +159,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@2025.03.0
uses: home-assistant/wheels@2025.02.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -187,22 +187,22 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Download env_file
uses: actions/download-artifact@v4.2.1
uses: actions/download-artifact@v4.1.9
with:
name: env_file
- name: Download build_constraints
uses: actions/download-artifact@v4.2.1
uses: actions/download-artifact@v4.1.9
with:
name: build_constraints
- name: Download requirements_diff
uses: actions/download-artifact@v4.2.1
uses: actions/download-artifact@v4.1.9
with:
name: requirements_diff
- name: Download requirements_all_wheels
uses: actions/download-artifact@v4.2.1
uses: actions/download-artifact@v4.1.9
with:
name: requirements_all_wheels
@@ -219,7 +219,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@2025.03.0
uses: home-assistant/wheels@2025.02.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
-1
View File
@@ -69,7 +69,6 @@ test-reports/
test-results.xml
test-output.xml
pytest-*.txt
junit.xml
# Translations
*.mo
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.0
rev: v0.9.8
hooks:
- id: ruff
args:
-2
View File
@@ -136,7 +136,6 @@ homeassistant.components.clicksend.*
homeassistant.components.climate.*
homeassistant.components.cloud.*
homeassistant.components.co2signal.*
homeassistant.components.comelit.*
homeassistant.components.command_line.*
homeassistant.components.config.*
homeassistant.components.configurator.*
@@ -412,7 +411,6 @@ homeassistant.components.recollect_waste.*
homeassistant.components.recorder.*
homeassistant.components.remember_the_milk.*
homeassistant.components.remote.*
homeassistant.components.remote_calendar.*
homeassistant.components.renault.*
homeassistant.components.reolink.*
homeassistant.components.repairs.*
+1 -1
View File
@@ -4,7 +4,7 @@
{
"label": "Run Home Assistant Core",
"type": "shell",
"command": "${command:python.interpreterPath} -m homeassistant -c ./config",
"command": "hass -c ./config",
"group": "test",
"presentation": {
"reveal": "always",
Generated
+4 -8
View File
@@ -570,8 +570,8 @@ build.json @home-assistant/supervisor
/tests/components/google_cloud/ @lufton @tronikos
/homeassistant/components/google_drive/ @tronikos
/tests/components/google_drive/ @tronikos
/homeassistant/components/google_generative_ai_conversation/ @tronikos @ivanlh
/tests/components/google_generative_ai_conversation/ @tronikos @ivanlh
/homeassistant/components/google_generative_ai_conversation/ @tronikos
/tests/components/google_generative_ai_conversation/ @tronikos
/homeassistant/components/google_mail/ @tkdrob
/tests/components/google_mail/ @tkdrob
/homeassistant/components/google_photos/ @allenporter
@@ -1183,8 +1183,6 @@ build.json @home-assistant/supervisor
/tests/components/prusalink/ @balloob
/homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45
/homeassistant/components/pterodactyl/ @elmurato
/tests/components/pterodactyl/ @elmurato
/homeassistant/components/pure_energie/ @klaasnicolaas
/tests/components/pure_energie/ @klaasnicolaas
/homeassistant/components/purpleair/ @bachya
@@ -1254,8 +1252,6 @@ build.json @home-assistant/supervisor
/tests/components/refoss/ @ashionky
/homeassistant/components/remote/ @home-assistant/core
/tests/components/remote/ @home-assistant/core
/homeassistant/components/remote_calendar/ @Thomas55555
/tests/components/remote_calendar/ @Thomas55555
/homeassistant/components/renault/ @epenet
/tests/components/renault/ @epenet
/homeassistant/components/renson/ @jimmyd-be
@@ -1533,8 +1529,8 @@ build.json @home-assistant/supervisor
/tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @Petro31 @PhracturedBlue @home-assistant/core
/tests/components/template/ @Petro31 @PhracturedBlue @home-assistant/core
/homeassistant/components/template/ @PhracturedBlue @home-assistant/core
/tests/components/template/ @PhracturedBlue @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks
Generated
+2 -2
View File
@@ -25,13 +25,13 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
# Install uv
RUN pip3 install uv==0.6.8
RUN pip3 install uv==0.6.1
WORKDIR /usr/src
-9
View File
@@ -178,15 +178,6 @@ _BLOCKING_CALLS: tuple[BlockingCall, ...] = (
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=SSLContext.set_default_verify_paths,
object=SSLContext,
function="set_default_verify_paths",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=Path.open,
object=Path,
+163 -141
View File
@@ -81,7 +81,6 @@ from .helpers import (
entity,
entity_registry,
floor_registry,
frame,
issue_registry,
label_registry,
recorder,
@@ -93,7 +92,6 @@ from .helpers.dispatcher import async_dispatcher_send_internal
from .helpers.storage import get_internal_store_manager
from .helpers.system_info import async_get_system_info
from .helpers.typing import ConfigType
from .loader import Integration
from .setup import (
# _setup_started is marked as protected to make it clear
# that it is not part of the public API and should not be used
@@ -300,6 +298,14 @@ async def async_setup_hass(
return hass
async def stop_hass(hass: core.HomeAssistant) -> None:
"""Stop hass."""
# Ask integrations to shut down. It's messy but we can't
# do a clean stop without knowing what is broken
with contextlib.suppress(TimeoutError):
async with hass.timeout.async_timeout(10):
await hass.async_stop()
hass = await create_hass()
if runtime_config.skip_pip or runtime_config.skip_pip_packages:
@@ -338,7 +344,7 @@ async def async_setup_hass(
if config_dict is None:
recovery_mode = True
await hass.async_stop(force=True)
await stop_hass(hass)
hass = await create_hass()
elif not basic_setup_success:
@@ -346,7 +352,7 @@ async def async_setup_hass(
"Unable to set up core integrations. Activating recovery mode"
)
recovery_mode = True
await hass.async_stop(force=True)
await stop_hass(hass)
hass = await create_hass()
elif any(
@@ -361,7 +367,7 @@ async def async_setup_hass(
old_logging = hass.data.get(DATA_LOGGING)
recovery_mode = True
await hass.async_stop(force=True)
await stop_hass(hass)
hass = await create_hass()
if old_logging:
@@ -435,10 +441,9 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
if DATA_REGISTRIES_LOADED in hass.data:
return
hass.data[DATA_REGISTRIES_LOADED] = None
entity.async_setup(hass)
frame.async_setup(hass)
template.async_setup(hass)
translation.async_setup(hass)
entity.async_setup(hass)
template.async_setup(hass)
await asyncio.gather(
create_eager_task(get_internal_store_manager(hass).async_initialize()),
create_eager_task(area_registry.async_load(hass)),
@@ -659,10 +664,11 @@ def _create_log_file(
err_handler = _RotatingFileHandlerWithoutShouldRollOver(
err_log_path, backupCount=1
)
try:
err_handler.doRollover()
except OSError as err:
_LOGGER.error("Error rolling over log file: %s", err)
try:
err_handler.doRollover()
except OSError as err:
_LOGGER.error("Error rolling over log file: %s", err)
return err_handler
@@ -712,25 +718,20 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
return domains
async def _async_resolve_domains_and_preload(
async def _async_resolve_domains_to_setup(
hass: core.HomeAssistant, config: dict[str, Any]
) -> tuple[dict[str, Integration], dict[str, Integration]]:
"""Resolve all dependencies and return integrations to set up.
The return value is a tuple of two dictionaries:
- The first dictionary contains integrations
specified by the configuration (including config entries).
- The second dictionary contains the same integrations as the first dictionary
together with all their dependencies.
"""
) -> tuple[set[str], dict[str, loader.Integration]]:
"""Resolve all dependencies and return list of domains to set up."""
domains_to_setup = _get_domains(hass, config)
needed_requirements: set[str] = set()
platform_integrations = conf_util.extract_platform_integrations(
config, BASE_PLATFORMS
)
# Ensure base platforms that have platform integrations are added to `domains`,
# so they can be setup first instead of discovering them later when a config
# entry setup task notices that it's needed and there is already a long line
# to use the import executor.
# Ensure base platforms that have platform integrations are added to
# to `domains_to_setup so they can be setup first instead of
# discovering them when later when a config entry setup task
# notices its needed and there is already a long line to use
# the import executor.
#
# For example if we have
# sensor:
@@ -746,78 +747,111 @@ async def _async_resolve_domains_and_preload(
# so this will be less of a problem in the future.
domains_to_setup.update(platform_integrations)
# Additionally process base platforms since we do not require the manifest
# to list them as dependencies.
# We want to later avoid lock contention when multiple integrations try to load
# their manifests at once.
# Also process integrations that are defined under base platforms
# to speed things up.
additional_domains_to_process = {
# Load manifests for base platforms and platform based integrations
# that are defined under base platforms right away since we do not require
# the manifest to list them as dependencies and we want to avoid the lock
# contention when multiple integrations try to load them at once
additional_manifests_to_load = {
*BASE_PLATFORMS,
*chain.from_iterable(platform_integrations.values()),
}
translations_to_load = additional_manifests_to_load.copy()
# Resolve all dependencies so we know all integrations
# that will have to be loaded and start right-away
integrations_or_excs = await loader.async_get_integrations(
hass, {*domains_to_setup, *additional_domains_to_process}
)
# Eliminate those missing or with invalid manifest
integrations_to_process = {
domain: itg
for domain, itg in integrations_or_excs.items()
if isinstance(itg, Integration)
}
integrations_dependencies = await loader.resolve_integrations_dependencies(
hass, integrations_to_process.values()
)
# Eliminate those without valid dependencies
integrations_to_process = {
domain: integrations_to_process[domain] for domain in integrations_dependencies
}
integration_cache: dict[str, loader.Integration] = {}
to_resolve: set[str] = domains_to_setup
while to_resolve or additional_manifests_to_load:
old_to_resolve: set[str] = to_resolve
to_resolve = set()
integrations_to_setup = {
domain: itg
for domain, itg in integrations_to_process.items()
if domain in domains_to_setup
}
all_integrations_to_setup = integrations_to_setup.copy()
all_integrations_to_setup.update(
(dep, loader.async_get_loaded_integration(hass, dep))
for domain in integrations_to_setup
for dep in integrations_dependencies[domain].difference(
all_integrations_to_setup
)
)
if additional_manifests_to_load:
to_get = {*old_to_resolve, *additional_manifests_to_load}
additional_manifests_to_load.clear()
else:
to_get = old_to_resolve
# Gather requirements for all integrations,
# their dependencies and after dependencies.
# To gather all the requirements we must ignore exceptions here.
# The exceptions will be detected and handled later in the bootstrap process.
integrations_after_dependencies = (
await loader.resolve_integrations_after_dependencies(
hass, integrations_to_process.values(), ignore_exceptions=True
)
)
integrations_requirements = {
domain: itg.requirements for domain, itg in integrations_to_process.items()
}
integrations_requirements.update(
(dep, loader.async_get_loaded_integration(hass, dep).requirements)
for deps in integrations_after_dependencies.values()
for dep in deps.difference(integrations_requirements)
)
all_requirements = set(chain.from_iterable(integrations_requirements.values()))
manifest_deps: set[str] = set()
resolve_dependencies_tasks: list[asyncio.Task[bool]] = []
integrations_to_process: list[loader.Integration] = []
for domain, itg in (await loader.async_get_integrations(hass, to_get)).items():
if not isinstance(itg, loader.Integration):
continue
integration_cache[domain] = itg
needed_requirements.update(itg.requirements)
# Make sure manifests for dependencies are loaded in the next
# loop to try to group as many as manifest loads in a single
# call to avoid the creating one-off executor jobs later in
# the setup process
additional_manifests_to_load.update(
dep
for dep in chain(itg.dependencies, itg.after_dependencies)
if dep not in integration_cache
)
if domain not in old_to_resolve:
continue
integrations_to_process.append(itg)
manifest_deps.update(itg.dependencies)
manifest_deps.update(itg.after_dependencies)
if not itg.all_dependencies_resolved:
resolve_dependencies_tasks.append(
create_eager_task(
itg.resolve_dependencies(),
name=f"resolve dependencies {domain}",
loop=hass.loop,
)
)
if unseen_deps := manifest_deps - integration_cache.keys():
# If there are dependencies, try to preload all
# the integrations manifest at once and add them
# to the list of requirements we need to install
# so we can try to check if they are already installed
# in a single call below which avoids each integration
# having to wait for the lock to do it individually
deps = await loader.async_get_integrations(hass, unseen_deps)
for dependant_domain, dependant_itg in deps.items():
if isinstance(dependant_itg, loader.Integration):
integration_cache[dependant_domain] = dependant_itg
needed_requirements.update(dependant_itg.requirements)
if resolve_dependencies_tasks:
await asyncio.gather(*resolve_dependencies_tasks)
for itg in integrations_to_process:
try:
all_deps = itg.all_dependencies
except RuntimeError:
# Integration.all_dependencies raises RuntimeError if
# dependencies could not be resolved
continue
for dep in all_deps:
if dep in domains_to_setup:
continue
domains_to_setup.add(dep)
to_resolve.add(dep)
_LOGGER.info("Domains to be set up: %s", domains_to_setup)
# Optimistically check if requirements are already installed
# ahead of setting up the integrations so we can prime the cache
# We do not wait for this since it's an optimization only
# We do not wait for this since its an optimization only
hass.async_create_background_task(
requirements.async_load_installed_versions(hass, all_requirements),
requirements.async_load_installed_versions(hass, needed_requirements),
"check installed requirements",
eager_start=True,
)
#
# Only add the domains_to_setup after we finish resolving
# as new domains are likely to added in the process
#
translations_to_load.update(domains_to_setup)
# Start loading translations for all integrations we are going to set up
# in the background so they are ready when we need them. This avoids a
# lot of waiting for the translation load lock and a thundering herd of
@@ -828,7 +862,6 @@ async def _async_resolve_domains_and_preload(
# hold the translation load lock and if anything is fast enough to
# wait for the translation load lock, loading will be done by the
# time it gets to it.
translations_to_load = {*all_integrations_to_setup, *additional_domains_to_process}
hass.async_create_background_task(
translation.async_load_integrations(hass, translations_to_load),
"load translations",
@@ -840,13 +873,13 @@ async def _async_resolve_domains_and_preload(
# in the setup process.
hass.async_create_background_task(
get_internal_store_manager(hass).async_preload(
[*PRELOAD_STORAGE, *all_integrations_to_setup]
[*PRELOAD_STORAGE, *domains_to_setup]
),
"preload storage",
eager_start=True,
)
return integrations_to_setup, all_integrations_to_setup
return domains_to_setup, integration_cache
async def _async_set_up_integrations(
@@ -856,84 +889,69 @@ async def _async_set_up_integrations(
watcher = _WatchPendingSetups(hass, _setup_started(hass))
watcher.async_start()
integrations, all_integrations = await _async_resolve_domains_and_preload(
domains_to_setup, integration_cache = await _async_resolve_domains_to_setup(
hass, config
)
# Detect all cycles
integrations_after_dependencies = (
await loader.resolve_integrations_after_dependencies(
hass, all_integrations.values(), set(all_integrations)
)
)
all_domains = set(integrations_after_dependencies)
domains = set(integrations) & all_domains
_LOGGER.info(
"Domains to be set up: %s | %s",
domains,
all_domains - domains,
)
async_set_domains_to_be_loaded(hass, all_domains)
stage_2_domains = domains_to_setup.copy()
# Initialize recorder
if "recorder" in all_domains:
if "recorder" in domains_to_setup:
recorder.async_initialize_recorder(hass)
# Initialize backup
if "backup" in all_domains:
if "backup" in domains_to_setup:
backup.async_initialize_backup(hass)
stages: list[tuple[str, set[str], int | None]] = [
stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [
*(
(name, domain_group, timeout)
(name, domain_group & domains_to_setup, timeout)
for name, domain_group, timeout in STAGE_0_INTEGRATIONS
),
("1", STAGE_1_INTEGRATIONS, STAGE_1_TIMEOUT),
("2", domains, STAGE_2_TIMEOUT),
("stage 1", STAGE_1_INTEGRATIONS & domains_to_setup, STAGE_1_TIMEOUT),
]
_LOGGER.info("Setting up stage 0")
for name, domain_group, timeout in stages:
stage_domains_unfiltered = domain_group & all_domains
if not stage_domains_unfiltered:
_LOGGER.info("Nothing to set up in stage %s: %s", name, domain_group)
_LOGGER.info("Setting up stage 0 and 1")
for name, domain_group, timeout in stage_0_and_1_domains:
if not domain_group:
continue
stage_domains = stage_domains_unfiltered - hass.config.components
if not stage_domains:
_LOGGER.info("Already set up stage %s: %s", name, stage_domains_unfiltered)
continue
stage_dep_domains_unfiltered = {
_LOGGER.info("Setting up %s: %s", name, domain_group)
to_be_loaded = domain_group.copy()
to_be_loaded.update(
dep
for domain in stage_domains
for dep in integrations_after_dependencies[domain]
if dep not in stage_domains
}
stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components
stage_all_domains = stage_domains | stage_dep_domains
_LOGGER.info(
"Setting up stage %s: %s | %s\nDependencies: %s | %s",
name,
stage_domains,
stage_domains_unfiltered - stage_domains,
stage_dep_domains,
stage_dep_domains_unfiltered - stage_dep_domains,
for domain in domain_group
if (integration := integration_cache.get(domain)) is not None
for dep in integration.all_dependencies
)
async_set_domains_to_be_loaded(hass, to_be_loaded)
stage_2_domains -= to_be_loaded
if timeout is None:
await _async_setup_multi_components(hass, stage_all_domains, config)
continue
await _async_setup_multi_components(hass, domain_group, config)
else:
try:
async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME):
await _async_setup_multi_components(hass, domain_group, config)
except TimeoutError:
_LOGGER.warning(
"Setup timed out for %s waiting on %s - moving forward",
name,
hass._active_tasks, # noqa: SLF001
)
# Add after dependencies when setting up stage 2 domains
async_set_domains_to_be_loaded(hass, stage_2_domains)
if stage_2_domains:
_LOGGER.info("Setting up stage 2: %s", stage_2_domains)
try:
async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME):
await _async_setup_multi_components(hass, stage_all_domains, config)
async with hass.timeout.async_timeout(
STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME
):
await _async_setup_multi_components(hass, stage_2_domains, config)
except TimeoutError:
_LOGGER.warning(
"Setup timed out for stage %s waiting on %s - moving forward",
name,
"Setup timed out for stage 2 waiting on %s - moving forward",
hass._active_tasks, # noqa: SLF001
)
@@ -1035,6 +1053,8 @@ async def _async_setup_multi_components(
config: dict[str, Any],
) -> None:
"""Set up multiple domains. Log on failure."""
# Avoid creating tasks for domains that were setup in a previous stage
domains_not_yet_setup = domains - hass.config.components
# Create setup tasks for base platforms first since everything will have
# to wait to be imported, and the sooner we can get the base platforms
# loaded the sooner we can start loading the rest of the integrations.
@@ -1044,7 +1064,9 @@ async def _async_setup_multi_components(
f"setup component {domain}",
eager_start=True,
)
for domain in sorted(domains, key=SETUP_ORDER_SORT_KEY, reverse=True)
for domain in sorted(
domains_not_yet_setup, key=SETUP_ORDER_SORT_KEY, reverse=True
)
}
results = await asyncio.gather(*futures.values(), return_exceptions=True)
for idx, domain in enumerate(futures):
+1 -2
View File
@@ -1,6 +1,5 @@
{
"domain": "motionblinds",
"name": "Motionblinds",
"integrations": ["motion_blinds", "motionblinds_ble"],
"iot_standards": ["matter"]
"integrations": ["motion_blinds", "motionblinds_ble"]
}
+1 -14
View File
@@ -24,7 +24,7 @@ from homeassistant.components.weather import (
API_METRIC: Final = "Metric"
ATTRIBUTION: Final = "Data provided by AccuWeather"
ATTR_CATEGORY_VALUE = "CategoryValue"
ATTR_CATEGORY: Final = "Category"
ATTR_DIRECTION: Final = "Direction"
ATTR_ENGLISH: Final = "English"
ATTR_LEVEL: Final = "level"
@@ -55,18 +55,5 @@ CONDITION_MAP = {
for cond_ha, cond_codes in CONDITION_CLASSES.items()
for cond_code in cond_codes
}
AIR_QUALITY_CATEGORY_MAP = {
1: "good",
2: "moderate",
3: "unhealthy",
4: "very_unhealthy",
5: "hazardous",
}
POLLEN_CATEGORY_MAP = {
1: "low",
2: "moderate",
3: "high",
4: "very_high",
}
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40)
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
@@ -75,11 +75,7 @@ class AccuWeatherObservationDataUpdateCoordinator(
async with timeout(10):
result = await self.accuweather.async_get_current_conditions()
except EXCEPTIONS as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="current_conditions_update_error",
translation_placeholders={"error": repr(error)},
) from error
raise UpdateFailed(error) from error
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
@@ -121,15 +117,9 @@ class AccuWeatherDailyForecastDataUpdateCoordinator(
"""Update data via library."""
try:
async with timeout(10):
result = await self.accuweather.async_get_daily_forecast(
language=self.hass.config.language
)
result = await self.accuweather.async_get_daily_forecast()
except EXCEPTIONS as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="forecast_update_error",
translation_placeholders={"error": repr(error)},
) from error
raise UpdateFailed(error) from error
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
"requirements": ["accuweather==4.2.0"],
"requirements": ["accuweather==4.1.0"],
"single_config_entry": true
}
+8 -20
View File
@@ -29,9 +29,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
AIR_QUALITY_CATEGORY_MAP,
API_METRIC,
ATTR_CATEGORY_VALUE,
ATTR_CATEGORY,
ATTR_DIRECTION,
ATTR_ENGLISH,
ATTR_LEVEL,
@@ -39,7 +38,6 @@ from .const import (
ATTR_VALUE,
ATTRIBUTION,
MAX_FORECAST_DAYS,
POLLEN_CATEGORY_MAP,
)
from .coordinator import (
AccuWeatherConfigEntry,
@@ -61,9 +59,9 @@ class AccuWeatherSensorDescription(SensorEntityDescription):
FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="AirQuality",
value_fn=lambda data: AIR_QUALITY_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]],
value_fn=lambda data: cast(str, data[ATTR_CATEGORY]),
device_class=SensorDeviceClass.ENUM,
options=list(AIR_QUALITY_CATEGORY_MAP.values()),
options=["good", "hazardous", "high", "low", "moderate", "unhealthy"],
translation_key="air_quality",
),
AccuWeatherSensorDescription(
@@ -85,9 +83,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
},
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="grass_pollen",
),
AccuWeatherSensorDescription(
@@ -111,9 +107,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
},
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="mold_pollen",
),
AccuWeatherSensorDescription(
@@ -121,9 +115,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
},
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="ragweed_pollen",
),
AccuWeatherSensorDescription(
@@ -189,18 +181,14 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
},
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="tree_pollen",
),
AccuWeatherSensorDescription(
key="UVIndex",
native_unit_of_measurement=UV_INDEX,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
},
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="uv_index_forecast",
),
AccuWeatherSensorDescription(
@@ -26,20 +26,10 @@
"state": {
"good": "Good",
"hazardous": "Hazardous",
"high": "High",
"low": "Low",
"moderate": "Moderate",
"unhealthy": "Unhealthy",
"very_unhealthy": "Very unhealthy"
},
"state_attributes": {
"options": {
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]",
"very_unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::very_unhealthy%]"
}
}
"unhealthy": "Unhealthy"
}
},
"apparent_temperature": {
@@ -72,10 +62,12 @@
"level": {
"name": "Level",
"state": {
"high": "High",
"low": "Low",
"moderate": "Moderate",
"very_high": "Very high"
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
}
}
}
@@ -89,10 +81,12 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
}
}
}
@@ -106,15 +100,6 @@
"steady": "Steady",
"rising": "Rising",
"falling": "Falling"
},
"state_attributes": {
"options": {
"state": {
"falling": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::falling%]",
"rising": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::rising%]",
"steady": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::steady%]"
}
}
}
},
"ragweed_pollen": {
@@ -123,10 +108,12 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
}
}
}
@@ -167,10 +154,12 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
}
}
}
@@ -181,10 +170,12 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
}
}
}
@@ -195,10 +186,12 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
}
}
}
@@ -229,14 +222,6 @@
}
}
},
"exceptions": {
"current_conditions_update_error": {
"message": "An error occurred while retrieving weather current conditions data from the AccuWeather API: {error}"
},
"forecast_update_error": {
"message": "An error occurred while retrieving weather forecast data from the AccuWeather API: {error}"
}
},
"system_health": {
"info": {
"can_reach_server": "Reach AccuWeather server",
+3 -3
View File
@@ -5,14 +5,14 @@
"data": {
"connection_type": "Select connection type"
},
"description": "Select connection type. Local requires heaters with Bluetooth"
"description": "Select connection type. Local requires heaters with bluetooth"
},
"local": {
"data": {
"wifi_ssid": "Wi-Fi SSID",
"wifi_pswd": "Wi-Fi password"
"wifi_pswd": "Wi-Fi Password"
},
"description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue LED starts blinking before pressing Submit. Configuring heater might take some minutes."
"description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue led starts blinking before pressing Submit. Configuring heater might take some minutes."
},
"cloud": {
"data": {
@@ -2,7 +2,6 @@
from __future__ import annotations
from decimal import Decimal
import logging
from typing import Any
@@ -15,7 +14,6 @@ from homeassistant.components.climate import (
FAN_MEDIUM,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
@@ -51,14 +49,6 @@ ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled"
ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp"
ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp"
ADVANTAGE_AIR_MYFAN = "autoAA"
ADVANTAGE_AIR_MYAUTO_MODE_SET = "myAutoModeCurrentSetMode"
HVAC_ACTIONS = {
"cool": HVACAction.COOLING,
"heat": HVACAction.HEATING,
"vent": HVACAction.FAN,
"dry": HVACAction.DRYING,
}
HVAC_MODES = [
HVACMode.OFF,
@@ -185,17 +175,6 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
return ADVANTAGE_AIR_HVAC_MODES.get(self._ac["mode"])
return HVACMode.OFF
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current running HVAC action."""
if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF:
return HVACAction.OFF
if self._ac["mode"] == "myauto":
return HVAC_ACTIONS.get(
self._ac.get(ADVANTAGE_AIR_MYAUTO_MODE_SET, HVACAction.OFF)
)
return HVAC_ACTIONS.get(self._ac["mode"])
@property
def fan_mode(self) -> str | None:
"""Return the current fan modes."""
@@ -294,22 +273,6 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
return HVACMode.HEAT_COOL
return HVACMode.OFF
@property
def hvac_action(self) -> HVACAction | None:
"""Return the HVAC action, inheriting from master AC if zone is open but idle if air is <= 5%."""
if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF:
return HVACAction.OFF
master_action = HVAC_ACTIONS.get(self._ac["mode"], HVACAction.OFF)
if self._ac["mode"] == "myauto":
master_action = HVAC_ACTIONS.get(
str(self._ac.get(ADVANTAGE_AIR_MYAUTO_MODE_SET)), HVACAction.OFF
)
if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN:
if self._zone["value"] <= Decimal(5):
return HVACAction.IDLE
return master_action
return HVACAction.OFF
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
@@ -7,4 +7,3 @@ ADVANTAGE_AIR_STATE_CLOSE = "close"
ADVANTAGE_AIR_STATE_ON = "on"
ADVANTAGE_AIR_STATE_OFF = "off"
ADVANTAGE_AIR_AUTOFAN_ENABLED = "aaAutoFanModeEnabled"
ADVANTAGE_AIR_NIGHT_MODE_ENABLED = "quietNightModeEnabled"
@@ -41,7 +41,7 @@ async def async_setup_entry(
entities.append(
AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND)
)
elif thing["channelDipState"] in [3, 10]: # 3 & 10 = "Garage door"
elif thing["channelDipState"] == 3: # 3 = "Garage door"
entities.append(
AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE)
)
@@ -9,7 +9,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .const import (
ADVANTAGE_AIR_AUTOFAN_ENABLED,
ADVANTAGE_AIR_NIGHT_MODE_ENABLED,
ADVANTAGE_AIR_STATE_OFF,
ADVANTAGE_AIR_STATE_ON,
)
@@ -33,8 +32,6 @@ async def async_setup_entry(
entities.append(AdvantageAirFreshAir(instance, ac_key))
if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]:
entities.append(AdvantageAirMyFan(instance, ac_key))
if ADVANTAGE_AIR_NIGHT_MODE_ENABLED in ac_device["info"]:
entities.append(AdvantageAirNightMode(instance, ac_key))
if things := instance.coordinator.data.get("myThings"):
entities.extend(
AdvantageAirRelay(instance, thing)
@@ -96,32 +93,6 @@ class AdvantageAirMyFan(AdvantageAirAcEntity, SwitchEntity):
await self.async_update_ac({ADVANTAGE_AIR_AUTOFAN_ENABLED: False})
class AdvantageAirNightMode(AdvantageAirAcEntity, SwitchEntity):
"""Representation of Advantage 'MySleep$aver' Mode control."""
_attr_icon = "mdi:weather-night"
_attr_name = "MySleep$aver"
_attr_device_class = SwitchDeviceClass.SWITCH
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an Advantage Air Night Mode control."""
super().__init__(instance, ac_key)
self._attr_unique_id += "-nightmode"
@property
def is_on(self) -> bool:
"""Return the Night Mode status."""
return self._ac[ADVANTAGE_AIR_NIGHT_MODE_ENABLED]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn Night Mode on."""
await self.async_update_ac({ADVANTAGE_AIR_NIGHT_MODE_ENABLED: True})
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn Night Mode off."""
await self.async_update_ac({ADVANTAGE_AIR_NIGHT_MODE_ENABLED: False})
class AdvantageAirRelay(AdvantageAirThingEntity, SwitchEntity):
"""Representation of Advantage Air Thing."""
@@ -51,7 +51,7 @@
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"title": "The {integration_title} YAML configuration import failed",
"description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
"description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
}
}
}
@@ -11,7 +11,7 @@
}
},
"discovery_confirm": {
"description": "Do you want to set up {model}?"
"description": "Do you want to setup {model}?"
}
},
"abort": {
+2 -13
View File
@@ -105,14 +105,7 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i
try:
await measurements.update()
except (AirlyError, ClientConnectorError) as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"entry": self.config_entry.title,
"error": repr(error),
},
) from error
raise UpdateFailed(error) from error
_LOGGER.debug(
"Requests remaining: %s/%s",
@@ -133,11 +126,7 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i
standards = measurements.current["standards"]
if index["description"] == NO_AIRLY_SENSORS:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="no_station",
translation_placeholders={"entry": self.config_entry.title},
)
raise UpdateFailed("Can't retrieve data: no Airly sensors in this area")
for value in values:
data[value["name"]] = value["value"]
for standard in standards:
@@ -36,13 +36,5 @@
"name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]"
}
}
},
"exceptions": {
"update_error": {
"message": "An error occurred while retrieving data from the Airly API for {entry}: {error}"
},
"no_station": {
"message": "An error occurred while retrieving data from the Airly API for {entry}: no measuring stations in this area"
}
}
}
+2 -2
View File
@@ -7,7 +7,7 @@
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"radius": "Station radius (miles; optional)"
"radius": "Station Radius (miles; optional)"
}
}
},
@@ -25,7 +25,7 @@
"step": {
"init": {
"data": {
"radius": "Station radius (miles)"
"radius": "Station Radius (miles)"
}
}
}
+7 -7
View File
@@ -91,7 +91,7 @@
"name": "Hydrogen fluoride"
},
"health_index": {
"name": "Health index"
"name": "Health Index"
},
"absolute_humidity": {
"name": "Absolute humidity"
@@ -112,10 +112,10 @@
"name": "Oxygen"
},
"performance_index": {
"name": "Performance index"
"name": "Performance Index"
},
"hydrogen_phosphide": {
"name": "Hydrogen phosphide"
"name": "Hydrogen Phosphide"
},
"relative_pressure": {
"name": "Relative pressure"
@@ -127,22 +127,22 @@
"name": "Refrigerant"
},
"silicon_hydride": {
"name": "Silicon hydride"
"name": "Silicon Hydride"
},
"noise": {
"name": "Noise"
},
"maximum_noise": {
"name": "Noise (maximum)"
"name": "Noise (Maximum)"
},
"radon": {
"name": "Radon"
},
"industrial_volatile_organic_compounds": {
"name": "VOCs (industrial)"
"name": "VOCs (Industrial)"
},
"virus_index": {
"name": "Virus index"
"name": "Virus Index"
}
}
}
@@ -102,8 +102,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError:
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception("Unknown error occurred")
except Exception: # noqa: BLE001
return self.async_abort(reason="unknown")
name = get_name(device)
@@ -161,8 +160,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError:
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception("Unknown error occurred")
except Exception: # noqa: BLE001
return self.async_abort(reason="unknown")
name = get_name(device)
self._discovered_devices[address] = Discovery(name, discovery_info, device)
@@ -32,8 +32,7 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN):
client = Airtouch5SimpleClient(user_input[CONF_HOST])
try:
await client.test_connection()
except Exception:
_LOGGER.exception("Unexpected exception")
except Exception: # noqa: BLE001
errors = {"base": "cannot_connect"}
else:
await self.async_set_unique_id(user_input[CONF_HOST])
@@ -8,7 +8,7 @@ from python_homeassistant_analytics import (
HomeassistantAnalyticsClient,
HomeassistantAnalyticsConnectionError,
)
from python_homeassistant_analytics.models import Environment, IntegrationType
from python_homeassistant_analytics.models import IntegrationType
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
@@ -81,7 +81,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
)
try:
addons = await client.get_addons()
integrations = await client.get_integrations(Environment.NEXT)
integrations = await client.get_integrations()
custom_integrations = await client.get_custom_integrations()
except HomeassistantAnalyticsConnectionError:
LOGGER.exception("Error connecting to Home Assistant analytics")
@@ -165,7 +165,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow):
)
try:
addons = await client.get_addons()
integrations = await client.get_integrations(Environment.NEXT)
integrations = await client.get_integrations()
custom_integrations = await client.get_custom_integrations()
except HomeassistantAnalyticsConnectionError:
LOGGER.exception("Error connecting to Home Assistant analytics")
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
"requirements": ["androidtvremote2==0.2.1"],
"requirements": ["androidtvremote2==0.2.0"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}
@@ -2,8 +2,6 @@
from __future__ import annotations
import logging
from anova_wifi import AnovaApi, InvalidLogin
import voluptuous as vol
@@ -13,10 +11,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class AnovaConfigFlow(ConfigFlow, domain=DOMAIN):
class AnovaConfligFlow(ConfigFlow, domain=DOMAIN):
"""Sets up a config flow for Anova."""
VERSION = 1
@@ -39,8 +35,7 @@ class AnovaConfigFlow(ConfigFlow, domain=DOMAIN):
await api.authenticate()
except InvalidLogin:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
except Exception: # noqa: BLE001
errors["base"] = "unknown"
else:
return self.async_create_entry(
@@ -22,7 +22,6 @@ from . import AnthemavConfigEntry
from .const import ANTHEMAV_UPDATE_SIGNAL, DOMAIN, MANUFACTURER
_LOGGER = logging.getLogger(__name__)
VOLUME_STEP = 0.01
async def async_setup_entry(
@@ -61,7 +60,6 @@ class AnthemAVR(MediaPlayerEntity):
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.SELECT_SOURCE
)
_attr_volume_step = VOLUME_STEP
def __init__(
self,
@@ -34,12 +34,10 @@ from .const import (
CONF_PROMPT,
CONF_RECOMMENDED,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
DOMAIN,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET,
)
_LOGGER = logging.getLogger(__name__)
@@ -130,29 +128,21 @@ class AnthropicOptionsFlow(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the options."""
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
errors: dict[str, str] = {}
if user_input is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if user_input[CONF_LLM_HASS_API] == "none":
user_input.pop(CONF_LLM_HASS_API)
return self.async_create_entry(title="", data=user_input)
if user_input.get(
CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large"
# Re-render the options again, now with the recommended options shown/hidden
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
if not errors:
return self.async_create_entry(title="", data=user_input)
else:
# Re-render the options again, now with the recommended options shown/hidden
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
options = {
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
CONF_PROMPT: user_input[CONF_PROMPT],
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
}
options = {
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
CONF_PROMPT: user_input[CONF_PROMPT],
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
}
suggested_values = options.copy()
if not suggested_values.get(CONF_PROMPT):
@@ -166,7 +156,6 @@ class AnthropicOptionsFlow(OptionsFlow):
return self.async_show_form(
step_id="init",
data_schema=schema,
errors=errors or None,
)
@@ -216,10 +205,6 @@ def anthropic_config_option_schema(
CONF_TEMPERATURE,
default=RECOMMENDED_TEMPERATURE,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_THINKING_BUDGET,
default=RECOMMENDED_THINKING_BUDGET,
): int,
}
)
return schema
@@ -13,8 +13,3 @@ CONF_MAX_TOKENS = "max_tokens"
RECOMMENDED_MAX_TOKENS = 1024
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0
CONF_THINKING_BUDGET = "thinking_budget"
RECOMMENDED_THINKING_BUDGET = 0
MIN_THINKING_BUDGET = 1024
THINKING_MODELS = ["claude-3-7-sonnet-20250219", "claude-3-7-sonnet-latest"]
+103 -209
View File
@@ -1,32 +1,23 @@
"""Conversation support for Anthropic."""
from collections.abc import AsyncGenerator, Callable, Iterable
from collections.abc import AsyncGenerator, Callable
import json
from typing import Any, Literal, cast
from typing import Any, Literal
import anthropic
from anthropic import AsyncStream
from anthropic._types import NOT_GIVEN
from anthropic.types import (
InputJSONDelta,
Message,
MessageParam,
MessageStreamEvent,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
RawContentBlockStopEvent,
RawMessageStartEvent,
RawMessageStopEvent,
RedactedThinkingBlock,
RedactedThinkingBlockParam,
SignatureDelta,
TextBlock,
TextBlockParam,
TextDelta,
ThinkingBlock,
ThinkingBlockParam,
ThinkingConfigDisabledParam,
ThinkingConfigEnabledParam,
ThinkingDelta,
ToolParam,
ToolResultBlockParam,
ToolUseBlock,
@@ -39,7 +30,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, intent, llm
from homeassistant.helpers import chat_session, device_registry as dr, intent, llm
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AnthropicConfigEntry
@@ -48,15 +39,11 @@ from .const import (
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
DOMAIN,
LOGGER,
MIN_THINKING_BUDGET,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET,
THINKING_MODELS,
)
# Max number of back and forth with the LLM to generate a response
@@ -84,101 +71,73 @@ def _format_tool(
)
def _convert_content(
chat_content: Iterable[conversation.Content],
) -> list[MessageParam]:
"""Transform HA chat_log content into Anthropic API format."""
messages: list[MessageParam] = []
def _message_convert(
message: Message,
) -> MessageParam:
"""Convert from class to TypedDict."""
param_content: list[TextBlockParam | ToolUseBlockParam] = []
for content in chat_content:
if isinstance(content, conversation.ToolResultContent):
tool_result_block = ToolResultBlockParam(
type="tool_result",
tool_use_id=content.tool_call_id,
content=json.dumps(content.tool_result),
for message_content in message.content:
if isinstance(message_content, TextBlock):
param_content.append(TextBlockParam(type="text", text=message_content.text))
elif isinstance(message_content, ToolUseBlock):
param_content.append(
ToolUseBlockParam(
type="tool_use",
id=message_content.id,
name=message_content.name,
input=message_content.input,
)
)
if not messages or messages[-1]["role"] != "user":
messages.append(
MessageParam(
role="user",
content=[tool_result_block],
)
)
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
tool_result_block,
]
else:
messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
elif isinstance(content, conversation.UserContent):
# Combine consequent user messages
if not messages or messages[-1]["role"] != "user":
messages.append(
MessageParam(
role="user",
content=content.content,
)
)
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
TextBlockParam(type="text", text=content.content),
]
else:
messages[-1]["content"].append( # type: ignore[attr-defined]
TextBlockParam(type="text", text=content.content)
)
elif isinstance(content, conversation.AssistantContent):
# Combine consequent assistant messages
if not messages or messages[-1]["role"] != "assistant":
messages.append(
MessageParam(
role="assistant",
content=[],
)
)
if content.content:
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(type="text", text=content.content)
)
if content.tool_calls:
messages[-1]["content"].extend( # type: ignore[union-attr]
[
ToolUseBlockParam(
type="tool_use",
id=tool_call.id,
name=tool_call.tool_name,
input=tool_call.tool_args,
)
for tool_call in content.tool_calls
]
)
else:
# Note: We don't pass SystemContent here as its passed to the API as the prompt
raise TypeError(f"Unexpected content type: {type(content)}")
return MessageParam(role=message.role, content=param_content)
return messages
def _convert_content(chat_content: conversation.Content) -> MessageParam:
"""Create tool response content."""
if isinstance(chat_content, conversation.ToolResultContent):
return MessageParam(
role="user",
content=[
ToolResultBlockParam(
type="tool_result",
tool_use_id=chat_content.tool_call_id,
content=json.dumps(chat_content.tool_result),
)
],
)
if isinstance(chat_content, conversation.AssistantContent):
return MessageParam(
role="assistant",
content=[
TextBlockParam(type="text", text=chat_content.content or ""),
*[
ToolUseBlockParam(
type="tool_use",
id=tool_call.id,
name=tool_call.tool_name,
input=tool_call.tool_args,
)
for tool_call in chat_content.tool_calls or ()
],
],
)
if isinstance(chat_content, conversation.UserContent):
return MessageParam(
role="user",
content=chat_content.content,
)
# Note: We don't pass SystemContent here as its passed to the API as the prompt
raise ValueError(f"Unexpected content type: {type(chat_content)}")
async def _transform_stream(
result: AsyncStream[MessageStreamEvent],
messages: list[MessageParam],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform the response stream into HA format.
A typical stream of responses might look something like the following:
- RawMessageStartEvent with no content
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
- RawContentBlockDeltaEvent with a ThinkingDelta
- RawContentBlockDeltaEvent with a ThinkingDelta
- RawContentBlockDeltaEvent with a ThinkingDelta
- ...
- RawContentBlockDeltaEvent with a SignatureDelta
- RawContentBlockStopEvent
- RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally)
- RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta)
- RawContentBlockStartEvent with an empty TextBlock
- RawContentBlockDeltaEvent with a TextDelta
- RawContentBlockDeltaEvent with a TextDelta
@@ -192,103 +151,44 @@ async def _transform_stream(
- RawContentBlockStopEvent
- RawMessageDeltaEvent with a stop_reason='tool_use'
- RawMessageStopEvent(type='message_stop')
Each message could contain multiple blocks of the same type.
"""
if result is None:
raise TypeError("Expected a stream of messages")
current_message: MessageParam | None = None
current_block: (
TextBlockParam
| ToolUseBlockParam
| ThinkingBlockParam
| RedactedThinkingBlockParam
| None
) = None
current_tool_args: str
current_tool_call: dict | None = None
async for response in result:
LOGGER.debug("Received response: %s", response)
if isinstance(response, RawMessageStartEvent):
if response.message.role != "assistant":
raise ValueError("Unexpected message role")
current_message = MessageParam(role=response.message.role, content=[])
elif isinstance(response, RawContentBlockStartEvent):
if isinstance(response, RawContentBlockStartEvent):
if isinstance(response.content_block, ToolUseBlock):
current_block = ToolUseBlockParam(
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input="",
)
current_tool_args = ""
current_tool_call = {
"id": response.content_block.id,
"name": response.content_block.name,
"input": "",
}
elif isinstance(response.content_block, TextBlock):
current_block = TextBlockParam(
type="text", text=response.content_block.text
)
yield {"role": "assistant"}
if response.content_block.text:
yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock):
current_block = ThinkingBlockParam(
type="thinking",
thinking=response.content_block.thinking,
signature=response.content_block.signature,
)
elif isinstance(response.content_block, RedactedThinkingBlock):
current_block = RedactedThinkingBlockParam(
type="redacted_thinking", data=response.content_block.data
)
LOGGER.debug(
"Some of Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
elif isinstance(response, RawContentBlockDeltaEvent):
if current_block is None:
raise ValueError("Unexpected delta without a block")
if isinstance(response.delta, InputJSONDelta):
current_tool_args += response.delta.partial_json
if current_tool_call is None:
raise ValueError("Unexpected delta without a tool call")
current_tool_call["input"] += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
text_block = cast(TextBlockParam, current_block)
text_block["text"] += response.delta.text
LOGGER.debug("yielding delta: %s", response.delta.text)
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["thinking"] += response.delta.thinking
elif isinstance(response.delta, SignatureDelta):
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["signature"] += response.delta.signature
elif isinstance(response, RawContentBlockStopEvent):
if current_block is None:
raise ValueError("Unexpected stop event without a current block")
if current_block["type"] == "tool_use":
tool_block = cast(ToolUseBlockParam, current_block)
tool_args = json.loads(current_tool_args)
tool_block["input"] = tool_args
if current_tool_call:
yield {
"tool_calls": [
llm.ToolInput(
id=tool_block["id"],
tool_name=tool_block["name"],
tool_args=tool_args,
id=current_tool_call["id"],
tool_name=current_tool_call["name"],
tool_args=json.loads(current_tool_call["input"]),
)
]
}
elif current_block["type"] == "thinking":
thinking_block = cast(ThinkingBlockParam, current_block)
LOGGER.debug("Thinking: %s", thinking_block["thinking"])
if current_message is None:
raise ValueError("Unexpected stop event without a current message")
current_message["content"].append(current_block) # type: ignore[union-attr]
current_block = None
elif isinstance(response, RawMessageStopEvent):
if current_message is not None:
messages.append(current_message)
current_message = None
current_tool_call = None
class AnthropicConversationEntity(
@@ -326,6 +226,18 @@ class AnthropicConversationEntity(
self.entry.add_update_listener(self._async_entry_update_listener)
)
async def async_process(
self, user_input: conversation.ConversationInput
) -> conversation.ConversationResult:
"""Process a sentence."""
with (
chat_session.async_get_chat_session(
self.hass, user_input.conversation_id
) as session,
conversation.async_get_chat_log(self.hass, session, user_input) as chat_log,
):
return await self._async_handle_message(user_input, chat_log)
async def _async_handle_message(
self,
user_input: conversation.ConversationInput,
@@ -354,50 +266,34 @@ class AnthropicConversationEntity(
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise TypeError("First message must be a system message")
messages = _convert_content(chat_log.content[1:])
messages = [_convert_content(content) for content in chat_log.content[1:]]
client = self.entry.runtime_data
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
model_args = {
"model": model,
"messages": messages,
"tools": tools or NOT_GIVEN,
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
"system": system.content,
"stream": True,
}
if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
model_args["thinking"] = ThinkingConfigEnabledParam(
type="enabled", budget_tokens=thinking_budget
)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
try:
stream = await client.messages.create(**model_args)
stream = await client.messages.create(
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
messages=messages,
tools=tools or NOT_GIVEN,
max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
system=system.content,
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
stream=True,
)
except anthropic.AnthropicError as err:
raise HomeAssistantError(
f"Sorry, I had a problem talking to Anthropic: {err}"
) from err
messages.extend(
_convert_content(
[
content
async for content in chat_log.async_add_delta_content_stream(
user_input.agent_id, _transform_stream(stream, messages)
)
if not isinstance(content, conversation.AssistantContent)
]
)
[
_convert_content(content)
async for content in chat_log.async_add_delta_content_stream(
user_input.agent_id, _transform_stream(stream)
)
]
)
if not chat_log.unresponded_tool_results:
@@ -409,9 +305,7 @@ class AnthropicConversationEntity(
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_speech(response_content.content or "")
return conversation.ConversationResult(
response=intent_response,
conversation_id=chat_log.conversation_id,
continue_conversation=chat_log.continue_conversation,
response=intent_response, conversation_id=chat_log.conversation_id
)
async def _async_entry_update_listener(
@@ -23,17 +23,12 @@
"max_tokens": "Maximum tokens to return in response",
"temperature": "Temperature",
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"recommended": "Recommended model settings",
"thinking_budget_tokens": "Thinking budget"
"recommended": "Recommended model settings"
},
"data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template.",
"thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking."
"prompt": "Instruct how the LLM should respond. This can be a template."
}
}
},
"error": {
"thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget."
}
}
}
@@ -57,7 +57,7 @@
"name": "Status date"
},
"dip_switch_settings": {
"name": "DIP switch settings"
"name": "Dip switch settings"
},
"low_battery_signal": {
"name": "Low battery signal"
@@ -1 +0,0 @@
"""Virtual integration: Apollo Automation."""
@@ -1,6 +0,0 @@
{
"domain": "apollo_automation",
"name": "Apollo Automation",
"integration_type": "virtual",
"supported_by": "esphome"
}
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyaprilaire"],
"requirements": ["pyaprilaire==0.8.1"]
"requirements": ["pyaprilaire==0.7.7"]
}
@@ -60,7 +60,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except AuthenticationFailed:
errors["base"] = "invalid_auth"
except Exception:
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
@@ -125,7 +125,7 @@ SAVE_DELAY = 10
@callback
def _async_local_fallback_intent_filter(result: RecognizeResult) -> bool:
"""Filter out intents that are not local fallback."""
return result.intent.name in (intent.INTENT_GET_STATE)
return result.intent.name in (intent.INTENT_GET_STATE, intent.INTENT_NEVERMIND)
@callback
@@ -649,7 +649,6 @@ class PipelineRun:
data["runner_data"] = self.runner_data
if self.tts_stream:
data["tts_output"] = {
"token": self.tts_stream.token,
"url": self.tts_stream.url,
"mime_type": self.tts_stream.content_type,
}
@@ -1296,7 +1295,6 @@ class PipelineRun:
tts_output = {
"media_id": tts_media_id,
"token": self.tts_stream.token,
"url": self.tts_stream.url,
"mime_type": self.tts_stream.content_type,
}
@@ -56,7 +56,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{
vol.Optional("message"): str,
vol.Optional("media_id"): str,
vol.Optional("preannounce_media_id"): str,
}
),
cv.has_at_least_one_key("message", "media_id"),
@@ -71,7 +70,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{
vol.Optional("start_message"): str,
vol.Optional("start_media_id"): str,
vol.Optional("preannounce_media_id"): str,
vol.Optional("extra_system_prompt"): str,
}
),
@@ -23,6 +23,9 @@ from homeassistant.components.assist_pipeline import (
vad,
)
from homeassistant.components.media_player import async_process_play_media_url
from homeassistant.components.tts import (
generate_media_source_id as tts_generate_media_source_id,
)
from homeassistant.core import Context, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import chat_session, entity
@@ -95,15 +98,9 @@ class AssistSatelliteAnnouncement:
original_media_id: str
"""The raw media ID before processing."""
tts_token: str | None
"""The TTS token of the media."""
media_id_source: Literal["url", "media_id", "tts"]
"""Source of the media ID."""
preannounce_media_id: str | None = None
"""Media ID to be played before announcement."""
class AssistSatelliteEntity(entity.Entity):
"""Entity encapsulating the state and functionality of an Assist satellite."""
@@ -180,7 +177,6 @@ class AssistSatelliteEntity(entity.Entity):
self,
message: str | None = None,
media_id: str | None = None,
preannounce_media_id: str | None = None,
) -> None:
"""Play and show an announcement on the satellite.
@@ -190,8 +186,6 @@ class AssistSatelliteEntity(entity.Entity):
If media_id is provided, it is played directly. It is possible
to omit the message and the satellite will not show any text.
If preannounce_media_id is provided, it is played before the announcement.
Calls async_announce with message and media id.
"""
await self._cancel_running_pipeline()
@@ -199,9 +193,7 @@ class AssistSatelliteEntity(entity.Entity):
if message is None:
message = ""
announcement = await self._resolve_announcement_media_id(
message, media_id, preannounce_media_id
)
announcement = await self._resolve_announcement_media_id(message, media_id)
if self._is_announcing:
raise SatelliteBusyError
@@ -228,7 +220,6 @@ class AssistSatelliteEntity(entity.Entity):
start_message: str | None = None,
start_media_id: str | None = None,
extra_system_prompt: str | None = None,
preannounce_media_id: str | None = None,
) -> None:
"""Start a conversation from the satellite.
@@ -238,8 +229,6 @@ class AssistSatelliteEntity(entity.Entity):
If start_media_id is provided, it is played directly. It is possible
to omit the message and the satellite will not show any text.
If preannounce_media_id is provided, it is played before the announcement.
Calls async_start_conversation.
"""
await self._cancel_running_pipeline()
@@ -255,15 +244,13 @@ class AssistSatelliteEntity(entity.Entity):
start_message = ""
announcement = await self._resolve_announcement_media_id(
start_message, start_media_id, preannounce_media_id
start_message, start_media_id
)
if self._is_announcing:
raise SatelliteBusyError
self._is_announcing = True
self._set_state(AssistSatelliteState.RESPONDING)
# Provide our start info to the LLM so it understands context of incoming message
if extra_system_prompt is not None:
self._extra_system_prompt = extra_system_prompt
@@ -293,7 +280,6 @@ class AssistSatelliteEntity(entity.Entity):
raise
finally:
self._is_announcing = False
self._set_state(AssistSatelliteState.IDLE)
async def async_start_conversation(
self, start_announcement: AssistSatelliteAnnouncement
@@ -484,27 +470,20 @@ class AssistSatelliteEntity(entity.Entity):
return vad.VadSensitivity.to_seconds(vad_sensitivity)
async def _resolve_announcement_media_id(
self,
message: str,
media_id: str | None,
preannounce_media_id: str | None = None,
self, message: str, media_id: str | None
) -> AssistSatelliteAnnouncement:
"""Resolve the media ID."""
media_id_source: Literal["url", "media_id", "tts"] | None = None
tts_token: str | None = None
if media_id:
original_media_id = media_id
else:
media_id_source = "tts"
# Synthesize audio and get URL
pipeline_id = self._resolve_pipeline()
pipeline = async_get_pipeline(self.hass, pipeline_id)
engine = tts.async_resolve_engine(self.hass, pipeline.tts_engine)
if engine is None:
raise HomeAssistantError(f"TTS engine {pipeline.tts_engine} not found")
tts_options: dict[str, Any] = {}
if pipeline.tts_voice is not None:
tts_options[tts.ATTR_VOICE] = pipeline.tts_voice
@@ -512,23 +491,14 @@ class AssistSatelliteEntity(entity.Entity):
if self.tts_options is not None:
tts_options.update(self.tts_options)
stream = tts.async_create_stream(
self.hass,
engine=engine,
language=pipeline.tts_language,
options=tts_options,
)
stream.async_set_message(message)
tts_token = stream.token
media_id = stream.url
original_media_id = tts.generate_media_source_id(
media_id = tts_generate_media_source_id(
self.hass,
message,
engine=engine,
engine=pipeline.tts_engine,
language=pipeline.tts_language,
options=tts_options,
)
original_media_id = media_id
if media_source.is_media_source_id(media_id):
if not media_id_source:
@@ -546,26 +516,6 @@ class AssistSatelliteEntity(entity.Entity):
# Resolve to full URL
media_id = async_process_play_media_url(self.hass, media_id)
# Resolve preannounce media id
if preannounce_media_id:
if media_source.is_media_source_id(preannounce_media_id):
preannounce_media = await media_source.async_resolve_media(
self.hass,
preannounce_media_id,
None,
)
preannounce_media_id = preannounce_media.url
# Resolve to full URL
preannounce_media_id = async_process_play_media_url(
self.hass, preannounce_media_id
)
return AssistSatelliteAnnouncement(
message=message,
media_id=media_id,
original_media_id=original_media_id,
tts_token=tts_token,
media_id_source=media_id_source,
preannounce_media_id=preannounce_media_id,
message, media_id, original_media_id, media_id_source
)
@@ -14,10 +14,6 @@ announce:
required: false
selector:
text:
preannounce_media_id:
required: false
selector:
text:
start_conversation:
target:
entity:
@@ -38,7 +34,3 @@ start_conversation:
required: false
selector:
text:
preannounce_media_id:
required: false
selector:
text:
@@ -23,10 +23,6 @@
"media_id": {
"name": "Media ID",
"description": "The media ID to announce instead of using text-to-speech."
},
"preannounce_media_id": {
"name": "Preannounce Media ID",
"description": "The media ID to play before the announcement."
}
}
},
@@ -45,10 +41,6 @@
"extra_system_prompt": {
"name": "Extra system prompt",
"description": "Provide background information to the AI about the request."
},
"preannounce_media_id": {
"name": "Preannounce Media ID",
"description": "The media ID to play before the start message or media."
}
}
}
@@ -66,28 +66,28 @@
"name": "Upload"
},
"load_avg_1m": {
"name": "Average load (1 min)"
"name": "Average load (1m)"
},
"load_avg_5m": {
"name": "Average load (5 min)"
"name": "Average load (5m)"
},
"load_avg_15m": {
"name": "Average load (15 min)"
"name": "Average load (15m)"
},
"24ghz_temperature": {
"name": "2.4GHz temperature"
"name": "2.4GHz Temperature"
},
"5ghz_temperature": {
"name": "5GHz temperature"
"name": "5GHz Temperature"
},
"cpu_temperature": {
"name": "CPU temperature"
"name": "CPU Temperature"
},
"5ghz_2_temperature": {
"name": "5GHz temperature (Radio 2)"
"name": "5GHz Temperature (Radio 2)"
},
"6ghz_temperature": {
"name": "6GHz temperature"
"name": "6GHz Temperature"
},
"cpu_usage": {
"name": "CPU usage"
@@ -14,7 +14,7 @@
"personal_access_token": "Personal Access Token (PAT)"
},
"description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.",
"title": "Add Azure DevOps project"
"title": "Add Azure DevOps Project"
},
"reauth_confirm": {
"data": {
@@ -32,7 +32,7 @@
"entity": {
"sensor": {
"build_id": {
"name": "{definition_name} latest build ID"
"name": "{definition_name} latest build id"
},
"finish_time": {
"name": "{definition_name} latest build finish time"
@@ -59,7 +59,7 @@
"name": "{definition_name} latest build start time"
},
"url": {
"name": "{definition_name} latest build URL"
"name": "{definition_name} latest build url"
},
"work_item_count": {
"name": "{item_type} {item_state} work items"
@@ -68,7 +68,7 @@
},
"exceptions": {
"authentication_failed": {
"message": "Could not authorize with Azure DevOps for {title}. You will need to update your Personal Access Token."
"message": "Could not authorize with Azure DevOps for {title}. You will need to update your personal access token."
}
}
}
@@ -141,7 +141,7 @@ class AzureStorageBackupAgent(BackupAgent):
"""Delete a backup file."""
blob = await self._find_blob_by_backup_id(backup_id)
if blob is None:
raise BackupNotFound(f"Backup {backup_id} not found")
return
await self._client.delete_blob(blob.name)
@handle_backup_errors
@@ -163,11 +163,11 @@ class AzureStorageBackupAgent(BackupAgent):
self,
backup_id: str,
**kwargs: Any,
) -> AgentBackup:
) -> AgentBackup | None:
"""Return a backup."""
blob = await self._find_blob_by_backup_id(backup_id)
if blob is None:
raise BackupNotFound(f"Backup {backup_id} not found")
return None
return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"]))
+1 -32
View File
@@ -1,9 +1,7 @@
"""The Backup integration."""
from homeassistant.config_entries import SOURCE_SYSTEM
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.backup import DATA_BACKUP
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType
@@ -20,12 +18,10 @@ from .agent import (
)
from .config import BackupConfig, CreateBackupParametersDict
from .const import DATA_MANAGER, DOMAIN
from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator
from .http import async_register_http_views
from .manager import (
BackupManager,
BackupManagerError,
BackupPlatformEvent,
BackupPlatformProtocol,
BackupReaderWriter,
BackupReaderWriterError,
@@ -56,7 +52,6 @@ __all__ = [
"BackupConfig",
"BackupManagerError",
"BackupNotFound",
"BackupPlatformEvent",
"BackupPlatformProtocol",
"BackupReaderWriter",
"BackupReaderWriterError",
@@ -79,8 +74,6 @@ __all__ = [
"suggested_filename_from_name_date",
]
PLATFORMS = [Platform.SENSOR]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@@ -135,28 +128,4 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_http_views(hass)
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
"""Set up a config entry."""
backup_manager: BackupManager = hass.data[DATA_MANAGER]
coordinator = BackupDataUpdateCoordinator(hass, entry, backup_manager)
await coordinator.async_config_entry_first_refresh()
entry.async_on_unload(coordinator.async_unsubscribe)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+2 -9
View File
@@ -41,8 +41,6 @@ class BackupAgent(abc.ABC):
) -> AsyncIterator[bytes]:
"""Download a backup file.
Raises BackupNotFound if the backup does not exist.
:param backup_id: The ID of the backup that was returned in async_list_backups.
:return: An async iterator that yields bytes.
"""
@@ -69,8 +67,6 @@ class BackupAgent(abc.ABC):
) -> None:
"""Delete a backup file.
Raises BackupNotFound if the backup does not exist.
:param backup_id: The ID of the backup that was returned in async_list_backups.
"""
@@ -83,11 +79,8 @@ class BackupAgent(abc.ABC):
self,
backup_id: str,
**kwargs: Any,
) -> AgentBackup:
"""Return a backup.
Raises BackupNotFound if the backup does not exist.
"""
) -> AgentBackup | None:
"""Return a backup."""
class LocalBackupAgent(BackupAgent):
+7 -4
View File
@@ -88,13 +88,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
self,
backup_id: str,
**kwargs: Any,
) -> AgentBackup:
) -> AgentBackup | None:
"""Return a backup."""
if not self._loaded_backups:
await self._load_backups()
if backup_id not in self._backups:
raise BackupNotFound(f"Backup {backup_id} not found")
return None
backup, backup_path = self._backups[backup_id]
if not await self._hass.async_add_executor_job(backup_path.exists):
@@ -107,7 +107,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
backup_path,
)
self._backups.pop(backup_id)
raise BackupNotFound(f"Backup {backup_id} not found")
return None
return backup
@@ -130,7 +130,10 @@ class CoreLocalBackupAgent(LocalBackupAgent):
if not self._loaded_backups:
await self._load_backups()
backup_path = self.get_backup_path(backup_id)
try:
backup_path = self.get_backup_path(backup_id)
except BackupNotFound:
return
await self._hass.async_add_executor_job(backup_path.unlink, True)
LOGGER.debug("Deleted backup located at %s", backup_path)
self._backups.pop(backup_id)
@@ -1,21 +0,0 @@
"""Config flow for Home Assistant Backup integration."""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import DOMAIN
class BackupConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Home Assistant Backup."""
VERSION = 1
async def async_step_system(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
return self.async_create_entry(title="Backup", data={})
+2 -2
View File
@@ -16,8 +16,8 @@ DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN)
LOGGER = getLogger(__package__)
EXCLUDE_FROM_BACKUP = [
"**/__pycache__/*",
"**/.DS_Store",
"__pycache__/*",
".DS_Store",
".HA_RESTORE",
"*.db-shm",
"*.log.*",
@@ -1,81 +0,0 @@
"""Coordinator for Home Assistant Backup integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.backup import (
async_subscribe_events,
async_subscribe_platform_events,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, LOGGER
from .manager import (
BackupManager,
BackupManagerState,
BackupPlatformEvent,
ManagerStateEvent,
)
type BackupConfigEntry = ConfigEntry[BackupDataUpdateCoordinator]
@dataclass
class BackupCoordinatorData:
"""Class to hold backup data."""
backup_manager_state: BackupManagerState
last_successful_automatic_backup: datetime | None
next_scheduled_automatic_backup: datetime | None
class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
"""Class to retrieve backup status."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
backup_manager: BackupManager,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=None,
)
self.unsubscribe: list[Callable[[], None]] = [
async_subscribe_events(hass, self._on_event),
async_subscribe_platform_events(hass, self._on_event),
]
self.backup_manager = backup_manager
@callback
def _on_event(self, event: ManagerStateEvent | BackupPlatformEvent) -> None:
"""Handle new event."""
LOGGER.debug("Received backup event: %s", event)
self.config_entry.async_create_task(self.hass, self.async_refresh())
async def _async_update_data(self) -> BackupCoordinatorData:
"""Update backup manager data."""
return BackupCoordinatorData(
self.backup_manager.state,
self.backup_manager.config.data.last_completed_automatic_backup,
self.backup_manager.config.data.schedule.next_automatic_backup,
)
@callback
def async_unsubscribe(self) -> None:
"""Unsubscribe from events."""
for unsub in self.unsubscribe:
unsub()
@@ -1,27 +0,0 @@
"""Diagnostics support for Home Assistant Backup integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .coordinator import BackupConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: BackupConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"backup_agents": [
{"name": agent.name, "agent_id": agent.agent_id}
for agent in coordinator.backup_manager.backup_agents.values()
],
"backup_config": async_redact_data(
coordinator.backup_manager.config.data.to_dict(), [CONF_PASSWORD]
),
}
-36
View File
@@ -1,36 +0,0 @@
"""Base for backup entities."""
from __future__ import annotations
from homeassistant.const import __version__ as HA_VERSION
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import BackupDataUpdateCoordinator
class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]):
"""Base entity for backup manager."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: BackupDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize base entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = entity_description.key
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, "backup_manager")},
manufacturer="Home Assistant",
model="Home Assistant Backup",
sw_version=HA_VERSION,
name="Backup",
entry_type=DeviceEntryType.SERVICE,
configuration_url="homeassistant://config/backup",
)
+4 -15
View File
@@ -15,7 +15,6 @@ from multidict import istr
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import frame
from homeassistant.util import slugify
from . import util
@@ -60,19 +59,11 @@ class DownloadBackupView(HomeAssistantView):
if agent_id not in manager.backup_agents:
return Response(status=HTTPStatus.BAD_REQUEST)
agent = manager.backup_agents[agent_id]
try:
backup = await agent.async_get_backup(backup_id)
except BackupNotFound:
return Response(status=HTTPStatus.NOT_FOUND)
backup = await agent.async_get_backup(backup_id)
# Check for None to be backwards compatible with the old BackupAgent API,
# this can be removed in HA Core 2025.10
if not backup:
frame.report_usage(
"returns None from BackupAgent.async_get_backup",
breaks_in_ha_version="2025.10",
integration_domain=agent_id.partition(".")[0],
)
# We don't need to check if the path exists, aiohttp.FileResponse will handle
# that
if backup is None:
return Response(status=HTTPStatus.NOT_FOUND)
headers = {
@@ -101,8 +92,6 @@ class DownloadBackupView(HomeAssistantView):
) -> StreamResponse | FileResponse | Response:
if agent_id in manager.local_backup_agents:
local_agent = manager.local_backup_agents[agent_id]
# We don't need to check if the path exists, aiohttp.FileResponse will
# handle that
path = local_agent.get_backup_path(backup_id)
return FileResponse(path=path.as_posix(), headers=headers)
+8 -79
View File
@@ -30,7 +30,6 @@ from homeassistant.backup_restore import (
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import (
frame,
instance_id,
integration_platform,
issue_registry as ir,
@@ -65,7 +64,6 @@ from .models import (
AgentBackup,
BackupError,
BackupManagerError,
BackupNotFound,
BackupReaderWriterError,
BaseBackup,
Folder,
@@ -120,7 +118,6 @@ class BackupManagerState(StrEnum):
IDLE = "idle"
CREATE_BACKUP = "create_backup"
BLOCKED = "blocked"
RECEIVE_BACKUP = "receive_backup"
RESTORE_BACKUP = "restore_backup"
@@ -229,20 +226,6 @@ class RestoreBackupEvent(ManagerStateEvent):
state: RestoreBackupState
@dataclass(frozen=True, kw_only=True, slots=True)
class BackupPlatformEvent:
"""Backup platform class."""
domain: str
@dataclass(frozen=True, kw_only=True, slots=True)
class BlockedEvent(ManagerStateEvent):
"""Backup manager blocked, Home Assistant is starting."""
manager_state: BackupManagerState = BackupManagerState.BLOCKED
class BackupPlatformProtocol(Protocol):
"""Define the format that backup platforms can have."""
@@ -357,14 +340,11 @@ class BackupManager:
self.remove_next_delete_event: Callable[[], None] | None = None
# Latest backup event and backup event subscribers
self.last_event: ManagerStateEvent = BlockedEvent()
self.last_action_event: ManagerStateEvent | None = None
self.last_event: ManagerStateEvent = IdleEvent()
self.last_non_idle_event: ManagerStateEvent | None = None
self._backup_event_subscriptions = hass.data[
DATA_BACKUP
].backup_event_subscriptions
self._backup_platform_event_subscriptions = hass.data[
DATA_BACKUP
].backup_platform_event_subscriptions
async def async_setup(self) -> None:
"""Set up the backup manager."""
@@ -374,19 +354,10 @@ class BackupManager:
self.known_backups.load(stored["backups"])
await self._reader_writer.async_validate_config(config=self.config)
await self._reader_writer.async_resume_restore_progress_after_restart(
on_progress=self.async_on_backup_event
)
async def set_manager_idle_after_start(hass: HomeAssistant) -> None:
"""Set manager to idle after start."""
self.async_on_backup_event(IdleEvent())
if self.state == BackupManagerState.BLOCKED:
# If we're not finishing a restore job, set the manager to idle after start
start.async_at_started(self.hass, set_manager_idle_after_start)
await self.load_platforms()
@property
@@ -475,9 +446,6 @@ class BackupManager:
LOGGER.debug("%s platforms loaded in total", len(self.platforms))
LOGGER.debug("%s agents loaded in total", len(self.backup_agents))
LOGGER.debug("%s local agents loaded in total", len(self.local_backup_agents))
event = BackupPlatformEvent(domain=integration_domain)
for subscription in self._backup_platform_event_subscriptions:
subscription(event)
async def async_pre_backup_actions(self) -> None:
"""Perform pre backup actions."""
@@ -680,8 +648,6 @@ class BackupManager:
)
for idx, result in enumerate(get_backup_results):
agent_id = agent_ids[idx]
if isinstance(result, BackupNotFound):
continue
if isinstance(result, BackupAgentError):
agent_errors[agent_id] = result
continue
@@ -693,14 +659,7 @@ class BackupManager:
continue
if isinstance(result, BaseException):
raise result # unexpected error
# Check for None to be backwards compatible with the old BackupAgent API,
# this can be removed in HA Core 2025.10
if not result:
frame.report_usage(
"returns None from BackupAgent.async_get_backup",
breaks_in_ha_version="2025.10",
integration_domain=agent_id.partition(".")[0],
)
continue
if backup is None:
if known_backup := self.known_backups.get(backup_id):
@@ -764,8 +723,6 @@ class BackupManager:
)
for idx, result in enumerate(delete_backup_results):
agent_id = agent_ids[idx]
if isinstance(result, BackupNotFound):
continue
if isinstance(result, BackupAgentError):
agent_errors[agent_id] = result
continue
@@ -875,7 +832,7 @@ class BackupManager:
agent_errors = {
backup_id: error
for backup_id, error in zip(backup_ids, delete_results, strict=True)
if error and not isinstance(error, BackupNotFound)
if error
}
if agent_errors:
LOGGER.error(
@@ -1307,20 +1264,7 @@ class BackupManager:
) -> None:
"""Initiate restoring a backup."""
agent = self.backup_agents[agent_id]
try:
backup = await agent.async_get_backup(backup_id)
except BackupNotFound as err:
raise BackupManagerError(
f"Backup {backup_id} not found in agent {agent_id}"
) from err
# Check for None to be backwards compatible with the old BackupAgent API,
# this can be removed in HA Core 2025.10
if not backup:
frame.report_usage(
"returns None from BackupAgent.async_get_backup",
breaks_in_ha_version="2025.10",
integration_domain=agent_id.partition(".")[0],
)
if not await agent.async_get_backup(backup_id):
raise BackupManagerError(
f"Backup {backup_id} not found in agent {agent_id}"
)
@@ -1349,8 +1293,8 @@ class BackupManager:
if (current_state := self.state) != (new_state := event.manager_state):
LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
self.last_event = event
if not isinstance(event, (BlockedEvent, IdleEvent)):
self.last_action_event = event
if not isinstance(event, IdleEvent):
self.last_non_idle_event = event
for subscription in self._backup_event_subscriptions:
subscription(event)
@@ -1408,20 +1352,7 @@ class BackupManager:
agent = self.backup_agents[agent_id]
except KeyError as err:
raise BackupManagerError(f"Invalid agent selected: {agent_id}") from err
try:
backup = await agent.async_get_backup(backup_id)
except BackupNotFound as err:
raise BackupManagerError(
f"Backup {backup_id} not found in agent {agent_id}"
) from err
# Check for None to be backwards compatible with the old BackupAgent API,
# this can be removed in HA Core 2025.10
if not backup:
frame.report_usage(
"returns None from BackupAgent.async_get_backup",
breaks_in_ha_version="2025.10",
integration_domain=agent_id.partition(".")[0],
)
if not await agent.async_get_backup(backup_id):
raise BackupManagerError(
f"Backup {backup_id} not found in agent {agent_id}"
)
@@ -1726,9 +1657,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
"""Filter to filter excludes."""
for exclude in excludes:
# The home assistant core configuration directory is added as "data"
# in the tar file, so we need to prefix that path to the filters.
if not path.full_match(f"data/{exclude}"):
if not path.match(exclude):
continue
LOGGER.debug("Ignoring %s because of %s", path, exclude)
return True
@@ -5,9 +5,8 @@
"codeowners": ["@home-assistant/core"],
"dependencies": ["http", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/backup",
"integration_type": "service",
"integration_type": "system",
"iot_class": "calculated",
"quality_scale": "internal",
"requirements": ["cronsim==2.6", "securetar==2025.2.1"],
"single_config_entry": true
"requirements": ["cronsim==2.6", "securetar==2025.2.1"]
}
-75
View File
@@ -1,75 +0,0 @@
"""Sensor platform for Home Assistant Backup integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import BackupConfigEntry, BackupCoordinatorData
from .entity import BackupManagerEntity
from .manager import BackupManagerState
@dataclass(kw_only=True, frozen=True)
class BackupSensorEntityDescription(SensorEntityDescription):
"""Description for Home Assistant Backup sensor entities."""
value_fn: Callable[[BackupCoordinatorData], str | datetime | None]
BACKUP_MANAGER_DESCRIPTIONS = (
BackupSensorEntityDescription(
key="backup_manager_state",
translation_key="backup_manager_state",
device_class=SensorDeviceClass.ENUM,
options=[state.value for state in BackupManagerState],
value_fn=lambda data: data.backup_manager_state,
),
BackupSensorEntityDescription(
key="next_scheduled_automatic_backup",
translation_key="next_scheduled_automatic_backup",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda data: data.next_scheduled_automatic_backup,
),
BackupSensorEntityDescription(
key="last_successful_automatic_backup",
translation_key="last_successful_automatic_backup",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda data: data.last_successful_automatic_backup,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BackupConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Sensor set up for backup config entry."""
coordinator = config_entry.runtime_data
async_add_entities(
BackupManagerSensor(coordinator, description)
for description in BACKUP_MANAGER_DESCRIPTIONS
)
class BackupManagerSensor(BackupManagerEntity, SensorEntity):
"""Sensor to track backup manager state."""
entity_description: BackupSensorEntityDescription
@property
def native_value(self) -> str | datetime | None:
"""Return native value of entity."""
return self.entity_description.value_fn(self.coordinator.data)
@@ -22,24 +22,5 @@
"name": "Create automatic backup",
"description": "Creates a new backup with automatic backup settings."
}
},
"entity": {
"sensor": {
"backup_manager_state": {
"name": "Backup Manager State",
"state": {
"idle": "Idle",
"create_backup": "Creating a backup",
"receive_backup": "Receiving a backup",
"restore_backup": "Restoring a backup"
}
},
"next_scheduled_automatic_backup": {
"name": "Next scheduled automatic backup"
},
"last_successful_automatic_backup": {
"name": "Last successful automatic backup"
}
}
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ async def handle_info(
"backups": list(backups.values()),
"last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup,
"last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
"last_action_event": manager.last_action_event,
"last_non_idle_event": manager.last_non_idle_event,
"next_automatic_backup": manager.config.data.schedule.next_automatic_backup,
"next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional,
"state": manager.state,
+13 -13
View File
@@ -23,7 +23,7 @@
"entity": {
"climate": {
"auto_comfort": {
"name": "Auto Comfort"
"name": "Auto comfort"
}
},
"fan": {
@@ -39,25 +39,25 @@
},
"number": {
"comfort_min_speed": {
"name": "Auto Comfort minimum speed"
"name": "Auto Comfort Minimum Speed"
},
"comfort_max_speed": {
"name": "Auto Comfort maximum speed"
"name": "Auto Comfort Maximum Speed"
},
"comfort_heat_assist_speed": {
"name": "Auto Comfort Heat Assist speed"
"name": "Auto Comfort Heat Assist Speed"
},
"return_to_auto_timeout": {
"name": "Return to Auto timeout"
"name": "Return to Auto Timeout"
},
"motion_sense_timeout": {
"name": "Motion sense timeout"
"name": "Motion Sense Timeout"
},
"light_return_to_auto_timeout": {
"name": "Light return to Auto timeout"
"name": "Light Return to Auto Timeout"
},
"light_auto_motion_timeout": {
"name": "Light motion sense timeout"
"name": "Light Motion Sense Timeout"
}
},
"sensor": {
@@ -76,10 +76,10 @@
},
"switch": {
"legacy_ir_remote_enable": {
"name": "Legacy IR remote"
"name": "Legacy IR Remote"
},
"led_indicators_enable": {
"name": "LED indicators"
"name": "Led Indicators"
},
"comfort_heat_assist_enable": {
"name": "Auto Comfort Heat Assist"
@@ -88,10 +88,10 @@
"name": "Beep"
},
"eco_enable": {
"name": "Eco mode"
"name": "Eco Mode"
},
"motion_sense_enable": {
"name": "Motion sense"
"name": "Motion Sense"
},
"return_to_auto_enable": {
"name": "Return to Auto"
@@ -103,7 +103,7 @@
"name": "Dim to Warm"
},
"light_return_to_auto_enable": {
"name": "Light return to Auto"
"name": "Light Return to Auto"
}
}
}
@@ -29,7 +29,7 @@
"description": "Manually configure your Bang & Olufsen device."
},
"zeroconf_confirm": {
"title": "Set up Bang & Olufsen device",
"title": "Setup Bang & Olufsen device",
"description": "Confirm the configuration of the {model}-{serial_number} @ {host}."
}
}
@@ -197,11 +197,11 @@
"services": {
"beolink_allstandby": {
"name": "Beolink all standby",
"description": "Sets all connected Beolink devices to standby."
"description": "Set all Connected Beolink devices to standby."
},
"beolink_expand": {
"name": "Beolink expand",
"description": "Adds devices to the current Beolink experience.",
"description": "Expand current Beolink experience.",
"fields": {
"all_discovered": {
"name": "All discovered",
@@ -221,7 +221,7 @@
},
"beolink_join": {
"name": "Beolink join",
"description": "Joins a Beolink experience.",
"description": "Join a Beolink experience.",
"fields": {
"beolink_jid": {
"name": "Beolink JID",
@@ -241,11 +241,11 @@
},
"beolink_leave": {
"name": "Beolink leave",
"description": "Leaves a Beolink experience."
"description": "Leave a Beolink experience."
},
"beolink_unexpand": {
"name": "Beolink unexpand",
"description": "Removes devices from the current Beolink experience.",
"description": "Unexpand from current Beolink experience.",
"fields": {
"beolink_jids": {
"name": "Beolink JIDs",
@@ -274,7 +274,7 @@
"message": "An error occurred while attempting to play {media_type}: {error_message}."
},
"invalid_grouping_entity": {
"message": "Entity with ID {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?"
"message": "Entity with id: {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?"
},
"invalid_sound_mode": {
"message": "{invalid_sound_mode} is an invalid sound mode. Valid values are: {valid_sound_modes}."
@@ -75,9 +75,6 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
# the player can have an ipv6 address, but the api is only available on ipv4
if discovery_info.ip_address.version != 4:
return self.async_abort(reason="no_ipv4_address")
if discovery_info.port is not None:
self._port = discovery_info.port
@@ -19,8 +19,7 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"no_ipv4_address": "No IPv4 address found."
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
@@ -18,9 +18,9 @@
"bleak==0.22.3",
"bleak-retry-connector==3.9.0",
"bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.4.5",
"bluetooth-data-tools==1.26.1",
"dbus-fast==2.43.0",
"habluetooth==3.37.0"
"bluetooth-auto-recovery==1.4.4",
"bluetooth-data-tools==1.23.4",
"dbus-fast==2.33.0",
"habluetooth==3.24.1"
]
}
@@ -4,14 +4,10 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_EMAIL, CONF_NAME
from homeassistant.core import HomeAssistant
from .coordinator import BringConfigEntry
TO_REDACT = {CONF_NAME, CONF_EMAIL}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: BringConfigEntry
@@ -19,10 +15,7 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
return {
"data": {
k: async_redact_data(v.to_dict(), TO_REDACT)
for k, v in config_entry.runtime_data.data.items()
},
"data": {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()},
"lists": [lst.to_dict() for lst in config_entry.runtime_data.lists],
"user_settings": config_entry.runtime_data.user_settings.to_dict(),
}
+3 -6
View File
@@ -77,12 +77,9 @@ class BringEventEntity(BringBaseEntity, EventEntity):
attributes = asdict(activity.content)
attributes["last_activity_by"] = next(
(
x.name
for x in bring_list.users.users
if x.publicUuid == activity.content.publicUserUuid
),
None,
x.name
for x in bring_list.users.users
if x.publicUuid == activity.content.publicUserUuid
)
self._trigger_event(
+1 -1
View File
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["bring_api"],
"quality_scale": "platinum",
"requirements": ["bring-api==1.1.0"]
"requirements": ["bring-api==1.0.2"]
}
+1 -9
View File
@@ -9,7 +9,6 @@ from homeassistant.const import CONF_HOST, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
@@ -26,14 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b
host, printer_type=printer_type, snmp_engine=snmp_engine
)
except (ConnectionError, SnmpError, TimeoutError) as error:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={
"device": entry.title,
"error": repr(error),
},
) from error
raise ConfigEntryNotReady from error
coordinator = BrotherDataUpdateCoordinator(hass, entry, brother)
await coordinator.async_config_entry_first_refresh()
@@ -26,7 +26,6 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]):
) -> None:
"""Initialize."""
self.brother = brother
self.device_name = config_entry.title
super().__init__(
hass,
@@ -42,12 +41,5 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]):
async with timeout(20):
data = await self.brother.async_update()
except (ConnectionError, SnmpError, UnsupportedModelError) as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"device": self.device_name,
"error": repr(error),
},
) from error
raise UpdateFailed(error) from error
return data
@@ -159,13 +159,5 @@
"name": "Last restart"
}
}
},
"exceptions": {
"cannot_connect": {
"message": "An error occurred while connecting to the {device} printer: {error}"
},
"update_error": {
"message": "An error occurred while retrieving data from the {device} printer: {error}"
}
}
}
@@ -153,27 +153,6 @@ def _has_min_duration(
return validate
def _has_positive_interval(
start_key: str, end_key: str, duration_key: str
) -> Callable[[dict[str, Any]], dict[str, Any]]:
"""Verify that the time span between start and end is greater than zero."""
def validate(obj: dict[str, Any]) -> dict[str, Any]:
if (duration := obj.get(duration_key)) is not None:
if duration <= datetime.timedelta(seconds=0):
raise vol.Invalid(f"Expected positive duration ({duration})")
return obj
if (start := obj.get(start_key)) and (end := obj.get(end_key)):
if start >= end:
raise vol.Invalid(
f"Expected end time to be after start time ({start}, {end})"
)
return obj
return validate
def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]:
"""Verify that all values are of the same type."""
@@ -302,7 +281,6 @@ SERVICE_GET_EVENTS_SCHEMA: Final = vol.All(
),
}
),
_has_positive_interval(EVENT_START_DATETIME, EVENT_END_DATETIME, EVENT_DURATION),
)
@@ -892,7 +870,6 @@ async def async_get_events_service(
end = start + service_call.data[EVENT_DURATION]
else:
end = service_call.data[EVENT_END_DATETIME]
calendar_event_list = await calendar.async_get_events(
calendar.hass, dt_util.as_local(start), dt_util.as_local(end)
)
+2 -2
View File
@@ -21,8 +21,8 @@
"step": {
"init": {
"data": {
"ffmpeg_arguments": "Arguments passed to FFmpeg for cameras",
"timeout": "Request timeout (seconds)"
"ffmpeg_arguments": "Arguments passed to ffmpeg for cameras",
"timeout": "Request Timeout (seconds)"
}
}
}
+58 -41
View File
@@ -16,21 +16,12 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_UUID
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN
IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
KNOWN_HOSTS_SCHEMA = vol.Schema(
{
vol.Optional(
CONF_KNOWN_HOSTS,
): SelectSelector(
SelectSelectorConfig(custom_value=True, options=[], multiple=True),
)
}
)
KNOWN_HOSTS_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
WANTED_UUID_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
@@ -39,6 +30,12 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self) -> None:
"""Initialize flow."""
self._ignore_cec = set[str]()
self._known_hosts = set[str]()
self._wanted_uuid = set[str]()
@staticmethod
@callback
def async_get_options_flow(
@@ -65,31 +62,48 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup."""
if user_input is not None:
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
return self.async_create_entry(
title="Google Cast",
data=self._get_data(known_hosts=known_hosts),
)
errors = {}
data = {CONF_KNOWN_HOSTS: self._known_hosts}
return self.async_show_form(step_id="config", data_schema=KNOWN_HOSTS_SCHEMA)
if user_input is not None:
bad_hosts = False
known_hosts = user_input[CONF_KNOWN_HOSTS]
known_hosts = [x.strip() for x in known_hosts.split(",") if x.strip()]
try:
known_hosts = KNOWN_HOSTS_SCHEMA(known_hosts)
except vol.Invalid:
errors["base"] = "invalid_known_hosts"
bad_hosts = True
else:
self._known_hosts = known_hosts
data = self._get_data()
if not bad_hosts:
return self.async_create_entry(title="Google Cast", data=data)
fields = {}
fields[vol.Optional(CONF_KNOWN_HOSTS, default="")] = str
return self.async_show_form(
step_id="config", data_schema=vol.Schema(fields), errors=errors
)
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup."""
data = self._get_data()
if user_input is not None or not onboarding.async_is_onboarded(self.hass):
return self.async_create_entry(title="Google Cast", data=self._get_data())
return self.async_create_entry(title="Google Cast", data=data)
return self.async_show_form(step_id="confirm")
def _get_data(
self, *, known_hosts: list[str] | None = None
) -> dict[str, list[str]]:
def _get_data(self):
return {
CONF_IGNORE_CEC: [],
CONF_KNOWN_HOSTS: known_hosts or [],
CONF_UUID: [],
CONF_IGNORE_CEC: list(self._ignore_cec),
CONF_KNOWN_HOSTS: list(self._known_hosts),
CONF_UUID: list(self._wanted_uuid),
}
@@ -109,24 +123,31 @@ class CastOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the Google Cast options."""
errors: dict[str, str] = {}
current_config = self.config_entry.data
if user_input is not None:
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
self.updated_config = dict(self.config_entry.data)
self.updated_config[CONF_KNOWN_HOSTS] = known_hosts
if self.show_advanced_options:
return await self.async_step_advanced_options()
self.hass.config_entries.async_update_entry(
self.config_entry, data=self.updated_config
bad_hosts, known_hosts = _string_to_list(
user_input.get(CONF_KNOWN_HOSTS, ""), KNOWN_HOSTS_SCHEMA
)
return self.async_create_entry(title="", data={})
if not bad_hosts:
self.updated_config = dict(current_config)
self.updated_config[CONF_KNOWN_HOSTS] = known_hosts
if self.show_advanced_options:
return await self.async_step_advanced_options()
self.hass.config_entries.async_update_entry(
self.config_entry, data=self.updated_config
)
return self.async_create_entry(title="", data={})
fields: dict[vol.Marker, type[str]] = {}
suggested_value = _list_to_string(current_config.get(CONF_KNOWN_HOSTS))
_add_with_suggestion(fields, CONF_KNOWN_HOSTS, suggested_value)
return self.async_show_form(
step_id="basic_options",
data_schema=self.add_suggested_values_to_schema(
KNOWN_HOSTS_SCHEMA, self.config_entry.data
),
data_schema=vol.Schema(fields),
errors=errors,
last_step=not self.show_advanced_options,
)
@@ -185,10 +206,6 @@ def _string_to_list(string, schema):
return invalid, items
def _trim_items(items: list[str]) -> list[str]:
return [x.strip() for x in items if x.strip()]
def _add_with_suggestion(
fields: dict[vol.Marker, type[str]], key: str, suggested_value: str
) -> None:
+1 -2
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, NotRequired, TypedDict
from typing import TYPE_CHECKING, TypedDict
from homeassistant.util.signal_type import SignalType
@@ -46,4 +46,3 @@ class HomeAssistantControllerData(TypedDict):
hass_uuid: str
client_id: str | None
refresh_token: str
app_id: NotRequired[str]
+4 -8
View File
@@ -7,7 +7,6 @@ from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING, ClassVar
from urllib.parse import urlparse
from uuid import UUID
import aiohttp
import attr
@@ -41,7 +40,7 @@ class ChromecastInfo:
is_dynamic_group = attr.ib(type=bool | None, default=None)
@property
def friendly_name(self) -> str | None:
def friendly_name(self) -> str:
"""Return the Friendly Name."""
return self.cast_info.friendly_name
@@ -51,7 +50,7 @@ class ChromecastInfo:
return self.cast_info.cast_type == CAST_TYPE_GROUP
@property
def uuid(self) -> UUID:
def uuid(self) -> bool:
"""Return the UUID."""
return self.cast_info.uuid
@@ -81,7 +80,7 @@ class ChromecastInfo:
"+label%3A%22integration%3A+cast%22"
)
_LOGGER.info(
_LOGGER.debug(
(
"Fetched cast details for unknown model '%s' manufacturer:"
" '%s', type: '%s'. Please %s"
@@ -112,10 +111,7 @@ class ChromecastInfo:
is_dynamic_group = False
http_group_status = None
http_group_status = dial.get_multizone_status(
# We pass services which will be used for the HTTP request, and we
# don't care about the host in http_group_status.dynamic_groups so
# we pass an empty string to simplify the code.
"",
None,
services=self.cast_info.services,
zconf=ChromeCastZeroconf.get_zeroconf(),
)
+1 -1
View File
@@ -14,7 +14,7 @@
"documentation": "https://www.home-assistant.io/integrations/cast",
"iot_class": "local_polling",
"loggers": ["casttube", "pychromecast"],
"requirements": ["PyChromecast==14.0.6"],
"requirements": ["PyChromecast==14.0.5"],
"single_config_entry": true,
"zeroconf": ["_googlecast._tcp.local."]
}
+1 -1
View File
@@ -7,11 +7,11 @@ show_lovelace_view:
integration: cast
domain: media_player
dashboard_path:
required: true
example: lovelace-cast
selector:
text:
view_path:
required: true
example: downstairs
selector:
text:
+4 -8
View File
@@ -6,11 +6,9 @@
},
"config": {
"title": "Google Cast configuration",
"description": "Known Hosts - A comma-separated list of hostnames or IP-addresses of cast devices, use if mDNS discovery is not working.",
"data": {
"known_hosts": "Add known host"
},
"data_description": {
"known_hosts": "Hostnames or IP-addresses of cast devices, use if mDNS discovery is not working"
"known_hosts": "Known hosts"
}
}
},
@@ -22,11 +20,9 @@
"step": {
"basic_options": {
"title": "[%key:component::cast::config::step::config::title%]",
"description": "[%key:component::cast::config::step::config::description%]",
"data": {
"known_hosts": "[%key:component::cast::config::step::config::data::known_hosts%]"
},
"data_description": {
"known_hosts": "[%key:component::cast::config::step::config::data_description::known_hosts%]"
}
},
"advanced_options": {
@@ -53,7 +49,7 @@
},
"dashboard_path": {
"name": "Dashboard path",
"description": "The URL path of the dashboard to show, defaults to lovelace if not specified."
"description": "The URL path of the dashboard to show."
},
"view_path": {
"name": "View path",
@@ -44,7 +44,7 @@ class ChaconDioConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except DIOChaconInvalidAuthError:
errors["base"] = "invalid_auth"
except Exception:
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
+15 -12
View File
@@ -18,12 +18,7 @@ from hass_nabucasa.cloud_api import (
)
from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5
from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupAgentError,
BackupNotFound,
)
from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -95,7 +90,9 @@ class CloudBackupAgent(BackupAgent):
:param backup_id: The ID of the backup that was returned in async_list_backups.
:return: An async iterator that yields bytes.
"""
backup = await self._async_get_backup(backup_id)
if not (backup := await self._async_get_backup(backup_id)):
raise BackupAgentError("Backup not found")
try:
content = await self._cloud.files.download(
storage_type=StorageType.BACKUP,
@@ -174,7 +171,9 @@ class CloudBackupAgent(BackupAgent):
:param backup_id: The ID of the backup that was returned in async_list_backups.
"""
backup = await self._async_get_backup(backup_id)
if not (backup := await self._async_get_backup(backup_id)):
return
try:
await async_files_delete_file(
self._cloud,
@@ -205,12 +204,16 @@ class CloudBackupAgent(BackupAgent):
self,
backup_id: str,
**kwargs: Any,
) -> AgentBackup:
) -> AgentBackup | None:
"""Return a backup."""
backup = await self._async_get_backup(backup_id)
if not (backup := await self._async_get_backup(backup_id)):
return None
return AgentBackup.from_dict(backup["Metadata"])
async def _async_get_backup(self, backup_id: str) -> FilesHandlerListEntry:
async def _async_get_backup(
self,
backup_id: str,
) -> FilesHandlerListEntry | None:
"""Return a backup."""
backups = await self._async_list_backups()
@@ -218,4 +221,4 @@ class CloudBackupAgent(BackupAgent):
if backup["Metadata"]["backup_id"] == backup_id:
return backup
raise BackupNotFound(f"Backup {backup_id} not found")
return None
+3 -15
View File
@@ -245,10 +245,6 @@ class CloudLoginView(HomeAssistantView):
name = "api:cloud:login"
@require_admin
async def post(self, request: web.Request) -> web.Response:
"""Handle login request."""
return await self._post(request)
@_handle_cloud_errors
@RequestDataValidator(
vol.Schema(
@@ -263,7 +259,7 @@ class CloudLoginView(HomeAssistantView):
)
)
)
async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle login request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
@@ -320,12 +316,8 @@ class CloudLogoutView(HomeAssistantView):
name = "api:cloud:logout"
@require_admin
async def post(self, request: web.Request) -> web.Response:
"""Handle logout request."""
return await self._post(request)
@_handle_cloud_errors
async def _post(self, request: web.Request) -> web.Response:
async def post(self, request: web.Request) -> web.Response:
"""Handle logout request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
@@ -408,13 +400,9 @@ class CloudForgotPasswordView(HomeAssistantView):
name = "api:cloud:forgot_password"
@require_admin
async def post(self, request: web.Request) -> web.Response:
"""Handle forgot password request."""
return await self._post(request)
@_handle_cloud_errors
@RequestDataValidator(vol.Schema({vol.Required("email"): str}))
async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle forgot password request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
+1 -1
View File
@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==0.94.0"],
"requirements": ["hass-nabucasa==0.92.0"],
"single_config_entry": true
}
+4 -4
View File
@@ -286,7 +286,7 @@ class CloudTTSEntity(TextToSpeechEntity):
return self._language
@property
def default_options(self) -> dict[str, str]:
def default_options(self) -> dict[str, Any]:
"""Return a dict include default options."""
return {
ATTR_AUDIO_OUTPUT: AudioOutput.MP3,
@@ -363,7 +363,7 @@ class CloudTTSEntity(TextToSpeechEntity):
_LOGGER.error("Voice error: %s", err)
return (None, None)
return (options[ATTR_AUDIO_OUTPUT], data)
return (str(options[ATTR_AUDIO_OUTPUT].value), data)
class CloudProvider(Provider):
@@ -404,7 +404,7 @@ class CloudProvider(Provider):
return [Voice(voice, voice) for voice in voices]
@property
def default_options(self) -> dict[str, str]:
def default_options(self) -> dict[str, Any]:
"""Return a dict include default options."""
return {
ATTR_AUDIO_OUTPUT: AudioOutput.MP3,
@@ -444,7 +444,7 @@ class CloudProvider(Provider):
_LOGGER.error("Voice error: %s", err)
return (None, None)
return options[ATTR_AUDIO_OUTPUT], data
return (str(options[ATTR_AUDIO_OUTPUT].value), data)
@callback
@@ -3,11 +3,11 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from aioelectricitymaps import (
ElectricityMaps,
ElectricityMapsError,
ElectricityMapsInvalidTokenError,
ElectricityMapsNoDataError,
)
@@ -36,8 +36,6 @@ TYPE_USE_HOME = "use_home_location"
TYPE_SPECIFY_COORDINATES = "specify_coordinates"
TYPE_SPECIFY_COUNTRY = "specify_country_code"
_LOGGER = logging.getLogger(__name__)
class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Co2signal."""
@@ -160,8 +158,7 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except ElectricityMapsNoDataError:
errors["base"] = "no_data"
except Exception:
_LOGGER.exception("Unexpected error occurred while checking API key")
except ElectricityMapsError:
errors["base"] = "unknown"
else:
if self.source == SOURCE_REAUTH:
@@ -6,7 +6,7 @@ import logging
from typing import cast
from aiocomelit.api import ComelitVedoAreaObject
from aiocomelit.const import AlarmAreaState
from aiocomelit.const import ALARM_AREAS, AlarmAreaState
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
@@ -20,9 +20,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
AWAY = "away"
@@ -59,7 +56,7 @@ async def async_setup_entry(
async_add_entities(
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data["alarm_areas"].values()
for device in coordinator.data[ALARM_AREAS].values()
)
@@ -95,7 +92,7 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
@property
def _area(self) -> ComelitVedoAreaObject:
"""Return area object."""
return self.coordinator.data["alarm_areas"][self._area_index]
return self.coordinator.data[ALARM_AREAS][self._area_index]
@property
def available(self) -> bool:
@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import cast
from aiocomelit import ComelitVedoZoneObject
from aiocomelit.const import ALARM_ZONES
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -16,9 +17,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
@@ -31,7 +29,7 @@ async def async_setup_entry(
async_add_entities(
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data["alarm_zones"].values()
for device in coordinator.data[ALARM_ZONES].values()
)
@@ -51,7 +49,7 @@ class ComelitVedoBinarySensorEntity(
) -> None:
"""Init sensor entity."""
self._api = coordinator.api
self._zone_index = zone.index
self._zone = zone
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
@@ -61,6 +59,4 @@ class ComelitVedoBinarySensorEntity(
@property
def is_on(self) -> bool:
"""Presence detected."""
return (
self.coordinator.data["alarm_zones"][self._zone_index].status_api == "0001"
)
return self.coordinator.data[ALARM_ZONES][self._zone.index].status_api == "0001"
+72 -62
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from enum import StrEnum
from typing import Any, TypedDict, cast
from typing import Any, cast
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE
@@ -16,17 +16,12 @@ from homeassistant.components.climate import (
UnitOfTemperature,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
class ClimaComelitMode(StrEnum):
"""Serial Bridge clima modes."""
@@ -47,23 +42,22 @@ class ClimaComelitCommand(StrEnum):
AUTO = "auto"
class ClimaComelitApiStatus(TypedDict):
"""Comelit Clima API status."""
hvac_mode: HVACMode
hvac_action: HVACAction
API_STATUS: dict[str, ClimaComelitApiStatus] = {
ClimaComelitMode.OFF: ClimaComelitApiStatus(
hvac_mode=HVACMode.OFF, hvac_action=HVACAction.OFF
),
ClimaComelitMode.LOWER: ClimaComelitApiStatus(
hvac_mode=HVACMode.COOL, hvac_action=HVACAction.COOLING
),
ClimaComelitMode.UPPER: ClimaComelitApiStatus(
hvac_mode=HVACMode.HEAT, hvac_action=HVACAction.HEATING
),
API_STATUS: dict[str, dict[str, Any]] = {
ClimaComelitMode.OFF: {
"action": "off",
"hvac_mode": HVACMode.OFF,
"hvac_action": HVACAction.OFF,
},
ClimaComelitMode.LOWER: {
"action": "lower",
"hvac_mode": HVACMode.COOL,
"hvac_action": HVACAction.COOLING,
},
ClimaComelitMode.UPPER: {
"action": "upper",
"hvac_mode": HVACMode.HEAT,
"hvac_action": HVACAction.HEATING,
},
}
MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = {
@@ -119,50 +113,70 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
self._attr_device_info = coordinator.platform_device_info(device, device.type)
self._update_attributes()
def _update_attributes(self) -> None:
"""Update class attributes."""
device = self.coordinator.data[CLIMATE][self._device.index]
if not isinstance(device.val, list):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="invalid_clima_data"
)
@property
def _clima(self) -> list[Any]:
"""Return clima device data."""
# CLIMATE has a 2 item tuple:
# - first for Clima
# - second for Humidifier
values = device.val[0]
return self.coordinator.data[CLIMATE][self._device.index].val[0]
_active = values[1]
_mode = values[2] # Values from API: "O", "L", "U"
_automatic = values[3] == ClimaComelitMode.AUTO
@property
def _api_mode(self) -> str:
"""Return device mode."""
# Values from API: "O", "L", "U"
return self._clima[2]
self._attr_current_temperature = values[0] / 10
@property
def _api_active(self) -> bool:
"Return device active/idle."
return self._clima[1]
self._attr_hvac_action = None
if _mode == ClimaComelitMode.OFF:
self._attr_hvac_action = HVACAction.OFF
if not _active:
self._attr_hvac_action = HVACAction.IDLE
if _mode in API_STATUS:
self._attr_hvac_action = API_STATUS[_mode]["hvac_action"]
@property
def _api_automatic(self) -> bool:
"""Return device in automatic/manual mode."""
return self._clima[3] == ClimaComelitMode.AUTO
self._attr_hvac_mode = None
if _mode == ClimaComelitMode.OFF:
self._attr_hvac_mode = HVACMode.OFF
if _automatic:
self._attr_hvac_mode = HVACMode.AUTO
if _mode in API_STATUS:
self._attr_hvac_mode = API_STATUS[_mode]["hvac_mode"]
@property
def target_temperature(self) -> float:
"""Set target temperature."""
return self._clima[4] / 10
self._attr_target_temperature = values[4] / 10
@property
def current_temperature(self) -> float:
"""Return current temperature."""
return self._clima[0] / 10
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_attributes()
super()._handle_coordinator_update()
@property
def hvac_mode(self) -> HVACMode | None:
"""HVAC current mode."""
if self._api_mode == ClimaComelitMode.OFF:
return HVACMode.OFF
if self._api_automatic:
return HVACMode.AUTO
if self._api_mode in API_STATUS:
return API_STATUS[self._api_mode]["hvac_mode"]
return None
@property
def hvac_action(self) -> HVACAction | None:
"""HVAC current action."""
if self._api_mode == ClimaComelitMode.OFF:
return HVACAction.OFF
if not self._api_active:
return HVACAction.IDLE
if self._api_mode in API_STATUS:
return API_STATUS[self._api_mode]["hvac_action"]
return None
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@@ -177,8 +191,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
await self.coordinator.api.set_clima_status(
self._device.index, ClimaComelitCommand.SET, target_temp
)
self._attr_target_temperature = target_temp
self.async_write_ha_state()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode."""
@@ -190,5 +202,3 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
await self.coordinator.api.set_clima_status(
self._device.index, MODE_TO_ACTION[hvac_mode]
)
self._attr_hvac_mode = hvac_mode
self.async_write_ha_state()
+13 -26
View File
@@ -2,19 +2,18 @@
from abc import abstractmethod
from datetime import timedelta
from typing import TypeVar
from typing import Any
from aiocomelit.api import (
AlarmDataObject,
ComelitCommonApi,
from aiocomelit import (
ComeliteSerialBridgeApi,
ComelitSerialBridgeObject,
ComelitVedoApi,
ComelitVedoAreaObject,
ComelitVedoZoneObject,
exceptions,
)
from aiocomelit.api import ComelitCommonApi
from aiocomelit.const import BRIDGE, VEDO
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -27,13 +26,7 @@ from .const import _LOGGER, DOMAIN
type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator]
T = TypeVar(
"T",
bound=dict[str, dict[int, ComelitSerialBridgeObject]] | AlarmDataObject,
)
class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Base coordinator for Comelit Devices."""
_hw_version: str
@@ -88,25 +81,23 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
hw_version=self._hw_version,
)
async def _async_update_data(self) -> T:
async def _async_update_data(self) -> dict[str, Any]:
"""Update device data."""
_LOGGER.debug("Polling Comelit %s host: %s", self._device, self._host)
try:
await self.api.login()
return await self._async_update_system_data()
except (CannotConnect, CannotRetrieveData) as err:
except (exceptions.CannotConnect, exceptions.CannotRetrieveData) as err:
raise UpdateFailed(repr(err)) from err
except CannotAuthenticate as err:
except exceptions.CannotAuthenticate as err:
raise ConfigEntryAuthFailed from err
@abstractmethod
async def _async_update_system_data(self) -> T:
async def _async_update_system_data(self) -> dict[str, Any]:
"""Class method for updating data."""
class ComelitSerialBridge(
ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]]
):
class ComelitSerialBridge(ComelitBaseCoordinator):
"""Queries Comelit Serial Bridge."""
_hw_version = "20003101"
@@ -124,14 +115,12 @@ class ComelitSerialBridge(
self.api = ComeliteSerialBridgeApi(host, port, pin)
super().__init__(hass, entry, BRIDGE, host)
async def _async_update_system_data(
self,
) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
async def _async_update_system_data(self) -> dict[str, Any]:
"""Specific method for updating data."""
return await self.api.get_all_devices()
class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
class ComelitVedoSystem(ComelitBaseCoordinator):
"""Queries Comelit VEDO system."""
_hw_version = "VEDO IP"
@@ -149,8 +138,6 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
self.api = ComelitVedoApi(host, port, pin)
super().__init__(hass, entry, VEDO, host)
async def _async_update_system_data(
self,
) -> AlarmDataObject:
async def _async_update_system_data(self) -> dict[str, Any]:
"""Specific method for updating data."""
return await self.api.get_all_areas_and_zones()
@@ -15,9 +15,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
+50 -36
View File
@@ -16,17 +16,14 @@ from homeassistant.components.humidifier import (
HumidifierEntity,
HumidifierEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
class HumidifierComelitMode(StrEnum):
"""Serial Bridge humidifier modes."""
@@ -124,41 +121,62 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
self._active_mode = active_mode
self._active_action = active_action
self._set_command = set_command
self._update_attributes()
def _update_attributes(self) -> None:
"""Update class attributes."""
device = self.coordinator.data[CLIMATE][self._device.index]
if not isinstance(device.val, list):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="invalid_clima_data"
)
@property
def _humidifier(self) -> list[Any]:
"""Return humidifier device data."""
# CLIMATE has a 2 item tuple:
# - first for Clima
# - second for Humidifier
values = device.val[1]
return self.coordinator.data[CLIMATE][self._device.index].val[1]
_active = values[1]
_mode = values[2] # Values from API: "O", "L", "U"
_automatic = values[3] == HumidifierComelitMode.AUTO
@property
def _api_mode(self) -> str:
"""Return device mode."""
# Values from API: "O", "L", "U"
return self._humidifier[2]
self._attr_action = HumidifierAction.IDLE
if _mode == HumidifierComelitMode.OFF:
self._attr_action = HumidifierAction.OFF
if _active and _mode == self._active_mode:
self._attr_action = self._active_action
@property
def _api_active(self) -> bool:
"Return device active/idle."
return self._humidifier[1]
self._attr_current_humidity = values[0] / 10
self._attr_is_on = _mode == self._active_mode
self._attr_mode = MODE_AUTO if _automatic else MODE_NORMAL
self._attr_target_humidity = values[4] / 10
@property
def _api_automatic(self) -> bool:
"""Return device in automatic/manual mode."""
return self._humidifier[3] == HumidifierComelitMode.AUTO
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_attributes()
super()._handle_coordinator_update()
@property
def target_humidity(self) -> float:
"""Set target humidity."""
return self._humidifier[4] / 10
@property
def current_humidity(self) -> float:
"""Return current humidity."""
return self._humidifier[0] / 10
@property
def is_on(self) -> bool | None:
"""Return true is humidifier is on."""
return self._api_mode == self._active_mode
@property
def mode(self) -> str | None:
"""Return current mode."""
return MODE_AUTO if self._api_automatic else MODE_NORMAL
@property
def action(self) -> HumidifierAction | None:
"""Return current action."""
if self._api_mode == HumidifierComelitMode.OFF:
return HumidifierAction.OFF
if self._api_active and self._api_mode == self._active_mode:
return self._active_action
return HumidifierAction.IDLE
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
@@ -174,16 +192,12 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
await self.coordinator.api.set_humidity_status(
self._device.index, HumidifierComelitCommand.SET, humidity
)
self._attr_target_humidity = humidity
self.async_write_ha_state()
async def async_set_mode(self, mode: str) -> None:
"""Set humidifier mode."""
await self.coordinator.api.set_humidity_status(
self._device.index, MODE_TO_ACTION[mode]
)
self._attr_mode = mode
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on."""
@@ -14,9 +14,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"requirements": ["aiocomelit==0.11.3"]
"requirements": ["aiocomelit==0.11.1"]
}

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