Compare commits

..

11 Commits

Author SHA1 Message Date
J. Nick Koston 51cddb88f5 Merge remote-tracking branch 'upstream/dev' into ci-uv-managed-python
# Conflicts:
#	.github/workflows/ci.yaml
2026-05-26 18:24:40 -05:00
J. Nick Koston 748a9842af Merge branch 'ci-cache-postgres-mariadb-deps' into ci-uv-managed-python 2026-05-21 14:33:44 -05:00
J. Nick Koston 55786dbdfc Use dpkg-architecture to derive multiarch lib path
So the ldconfig workaround also works on non-x86_64 runners.
2026-05-21 14:32:58 -05:00
J. Nick Koston e88c03a437 Merge branch 'dev' into ci-cache-postgres-mariadb-deps 2026-05-21 13:37:03 -05:00
J. Nick Koston dbc0dc1ea6 Install Python eagerly via setup-uv-python composite
setup-uv only sets UV_PYTHON, it does not actually fetch the
interpreter; uv installs it lazily on the first 'uv venv' or
'uv pip install'. When the venv cache hits, no uv command runs, so
the cached venv's bin/python3 symlink points at an interpreter that
was never installed in this job and the next step that activates the
venv aborts with 'broken symlink'.

Extract setup-uv plus an explicit 'uv python install' into the
.github/actions/setup-uv-python composite action so every job that
restores the venv ends up with a real Python at the expected path.
Enable cache-python in the wrapper so subsequent jobs reuse astral's
download instead of refetching it.
2026-05-21 13:16:39 -05:00
J. Nick Koston 31271876bf Pin uv version in every setup-uv call to skip manifest fetch
Mirrors esphome/esphome#16534. Without an explicit version, setup-uv
fetches uv.ndjson from raw.githubusercontent.com on every cache miss,
which periodically times out and fails the job. Expose the uv version
from requirements.txt via the info job and pass it to every setup-uv
call. Also set ignore-nothing-to-cache: true so jobs that do not touch
uv (e.g. gen-copilot-instructions) no longer fail on the post-step
cache save.
2026-05-21 13:08:57 -05:00
J. Nick Koston d5c31332b5 Switch CI to astral-managed Python via setup-uv
Replace actions/setup-python with astral-sh/setup-uv so every job uses
the python-build-standalone interpreter astral ships, which bakes in
the PEP 744 tail call interpreter on 3.14. setup-uv handles both
installing uv and provisioning the requested Python version, so the
venv bootstrap uses 'uv venv' instead of 'python -m venv' and there is
no longer a separate uv install step on cache miss.

Bumps CACHE_VERSION so the old setup-python venv caches are invalidated;
the venv symlinks would otherwise point at the absent hostedtoolcache
interpreter.
2026-05-21 13:05:30 -05:00
J. Nick Koston 3f0c93c26c Merge branch 'dev' into ci-cache-postgres-mariadb-deps 2026-05-21 12:48:19 -05:00
J. Nick Koston 07ed913ba2 Extract apt caching into composite action with alternatives workaround
Wrap awalsh128/cache-apt-pkgs-action in .github/actions/cache-apt-packages
so every job uses the same pattern, and route /usr/lib/x86_64-linux-gnu
subdirectories through ldconfig. The upstream action does not run postinst
on cache restore so update-alternatives symlinks (libblas, liblapack via
ffmpeg) never appear; adding the subdirs to ld.so.conf.d lets the linker
find the real libraries without those symlinks.
2026-05-21 10:45:13 -05:00
J. Nick Koston b7905b163f Run ldconfig after cache-apt-pkgs-action restore
The action restores cached .deb files to disk but skips dpkg-trigger so
/etc/ld.so.cache stays stale and ctypes-based loaders (eg opuslib)
cannot find libopus.so.0. Add an explicit ldconfig step after each
action call.
2026-05-21 10:02:39 -05:00
J. Nick Koston c712b07da3 Switch CI apt caching to awalsh128/cache-apt-pkgs-action 2026-05-21 09:42:20 -05:00
894 changed files with 5692 additions and 47591 deletions
@@ -24,7 +24,6 @@ The following platforms have extra guidelines:
## Entity platforms
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
## Integration Quality Scale
@@ -0,0 +1,42 @@
name: Set up uv and managed Python
description: >-
Pins uv (avoids the raw.githubusercontent.com manifest fetch on cache miss)
and proactively installs the requested Python so cached venvs created with
`uv venv` resolve their interpreter symlinks in jobs that only restore the
venv. setup-uv alone only sets UV_PYTHON, it does not actually fetch the
interpreter until uv first uses it, so jobs that just activate the venv
blow up with broken symlinks on cache hit.
inputs:
python-version:
description: The Python version uv should install and use.
required: true
uv-version:
description: The uv version setup-uv should install.
required: true
outputs:
python-version:
description: The Python version uv reports as installed.
value: ${{ steps.uv.outputs.python-version }}
runs:
using: composite
steps:
- name: Set up uv
id: uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ inputs.uv-version }}
python-version: ${{ inputs.python-version }}
# Persist astral's managed Python across jobs so 'uv venv' below is
# fast on the second job onwards.
cache-python: true
# Lint-only and codegen jobs touch no Python deps, so the post-step
# cache save would otherwise abort the job.
ignore-nothing-to-cache: true
- name: Install Python interpreter
shell: bash
env:
PYTHON_VERSION: ${{ inputs.python-version }}
run: uv python install "${PYTHON_VERSION}"
-1
View File
@@ -43,7 +43,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
## Good practices
@@ -27,7 +27,6 @@ The following platforms have extra guidelines:
## Entity platforms
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
## Integration Quality Scale
+7 -7
View File
@@ -344,13 +344,13 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -380,7 +380,7 @@ jobs:
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
@@ -394,7 +394,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
@@ -523,14 +523,14 @@ jobs:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -543,7 +543,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
+7 -7
View File
@@ -36,7 +36,7 @@
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
@@ -90,7 +90,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -352,7 +352,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -961,7 +961,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1100,7 +1100,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1325,7 +1325,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1383,7 +1383,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
+46 -48
View File
@@ -37,9 +37,9 @@ on:
type: boolean
env:
CACHE_VERSION: 3
CACHE_VERSION: 4
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.7"
HA_SHORT_VERSION: "2026.6"
ADDITIONAL_PYTHON_VERSIONS: "[]"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
@@ -89,6 +89,8 @@ jobs:
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
postgresql_groups: ${{ steps.info.outputs.postgresql_groups }}
python_versions: ${{ steps.info.outputs.python_versions }}
default_python: ${{ steps.info.outputs.default_python }}
uv_version: ${{ steps.info.outputs.uv_version }}
test_full_suite: ${{ steps.info.outputs.test_full_suite }}
test_group_count: ${{ steps.info.outputs.test_group_count }}
test_groups: ${{ steps.info.outputs.test_groups }}
@@ -235,6 +237,11 @@ jobs:
echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT
echo "python_versions: ${all_python_versions}"
echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT
echo "default_python: ${default_python}"
echo "default_python=${default_python}" >> $GITHUB_OUTPUT
uv_version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)
echo "uv_version: ${uv_version}"
echo "uv_version=${uv_version}" >> $GITHUB_OUTPUT
echo "test_full_suite: ${test_full_suite}"
echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT
echo "integrations_glob: ${integrations_glob}"
@@ -344,12 +351,12 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
- name: Set up uv and Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -397,21 +404,13 @@ jobs:
libudev-dev
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Read uv version from requirements.txt
if: steps.cache-venv.outputs.cache-hit != 'true'
id: read-uv-version
run: |
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
- name: Set up uv
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ steps.read-uv-version.outputs.version }}
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
id: create-venv
env:
PYTHON_VERSION: ${{ steps.python.outputs.python-version }}
run: |
python -m venv venv
uv venv venv --python "${PYTHON_VERSION}"
. venv/bin/activate
python --version
uv pip install -r requirements.txt
@@ -419,7 +418,6 @@ jobs:
uv pip install -e . --config-settings editable_mode=compat
- name: Dump pip freeze
run: |
python -m venv venv
. venv/bin/activate
python --version
uv pip freeze >> pip_freeze.txt
@@ -480,10 +478,10 @@ jobs:
version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -517,10 +515,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -553,10 +551,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Run gen_copilot_instructions.py
run: |
python -m script.gen_copilot_instructions validate
@@ -608,10 +606,10 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -659,10 +657,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -712,10 +710,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -763,10 +761,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Generate partial mypy restore key
id: generate-mypy-key
run: |
@@ -840,10 +838,10 @@ jobs:
execute_install_scripts: true
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -905,10 +903,10 @@ jobs:
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -1047,10 +1045,10 @@ jobs:
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -1203,10 +1201,10 @@ jobs:
version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -1371,10 +1369,10 @@ jobs:
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
category: "/language:python"
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
issues: write # To lock issues
pull-requests: write # To lock pull requests
steps:
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:
github-token: ${{ github.token }}
issue-inactive-days: "30"
+68 -26
View File
@@ -20,36 +20,22 @@ jobs:
permissions:
issues: write # To label and close stale issues
pull-requests: write # To label and close stale PRs
actions: write # To delete stalebot state
steps:
# Generate a token for the GitHub App, we use this method to avoid
# hitting API limits for our GitHub actions + have a higher rate limit.
- name: Generate app token
id: token
# Pinned to a specific version of the action for security reasons
# v3.2.0
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
# The 60 day stale policy for PRs
# Used for:
# - PRs
# - No PRs marked as no-stale
# The 90 day stale policy for issues
# Used for:
# - Issues
# - No issues marked as no-stale or help-wanted
- name: 60 days stale PRs policy and 90 days stale issue policy
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
# - No issues (-1)
- name: 60 days stale PRs policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ steps.token.outputs.token }}
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
days-before-close: 7
days-before-issue-stale: -1
days-before-issue-close: -1
operations-per-run: 150
remove-stale-when-updated: true
operations-per-run: 350
# pr policy
days-before-pr-stale: 60
days-before-pr-close: 7
stale-pr-label: "stale"
exempt-pr-labels: "no-stale"
stale-pr-message: >
@@ -62,9 +48,65 @@ jobs:
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
# issue policy
days-before-issue-stale: 90
days-before-issue-close: 7
# Generate a token for the GitHub App, we use this method to avoid
# hitting API limits for our GitHub actions + have a higher rate limit.
# This is only used for issues.
- name: Generate app token
id: token
# Pinned to a specific version of the action for security reasons
# v3.2.0
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
# The 90 day stale policy for issues
# Used for:
# - Issues
# - No issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: 90 days stale issues
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90
days-before-close: 7
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 250
remove-stale-when-updated: true
stale-issue-label: "stale"
exempt-issue-labels: "no-stale,help-wanted,needs-more-information"
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
high number of incoming GitHub notifications, we have to clean some
of the old issues, as many of them have already been resolved with
the latest updates.
Please make sure to update to the latest Home Assistant version and
check if that solves the issue. Let us know if that works for you by
adding a comment 👍
This issue has now been marked as stale and will be closed if no
further activity occurs. Thank you for your contributions.
# The 30 day stale policy for issues
# Used for:
# - Issues that are pending more information (incomplete issues)
# - No Issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: Needs more information stale issues policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information"
days-before-stale: 14
days-before-close: 7
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 250
remove-stale-when-updated: true
stale-issue-label: "stale"
exempt-issue-labels: "no-stale,help-wanted"
stale-issue-message: >
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.14
rev: v0.15.13
hooks:
- id: ruff-check
args:
-1
View File
@@ -286,7 +286,6 @@ homeassistant.components.huawei_lte.*
homeassistant.components.humidifier.*
homeassistant.components.husqvarna_automower.*
homeassistant.components.huum.*
homeassistant.components.hvv_departures.*
homeassistant.components.hydrawise.*
homeassistant.components.hyperion.*
homeassistant.components.hypontech.*
-1
View File
@@ -33,7 +33,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
## Good practices
Generated
+2 -8
View File
@@ -501,8 +501,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom
/homeassistant/components/envertech_evt800/ @daniel-bergmann-00
/tests/components/envertech_evt800/ @daniel-bergmann-00
/homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie
/homeassistant/components/ephember/ @ttroy50 @roberty99
@@ -720,8 +718,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/heatmiser/ @andylockran
/homeassistant/components/hegel/ @boazca
/tests/components/hegel/ @boazca
/homeassistant/components/helty/ @ebaschiera
/tests/components/helty/ @ebaschiera
/homeassistant/components/heos/ @andrewsayre
/tests/components/heos/ @andrewsayre
/homeassistant/components/here_travel_time/ @eifinger
@@ -840,8 +836,6 @@ CLAUDE.md @home-assistant/core
/tests/components/imgw_pib/ @bieniu
/homeassistant/components/immich/ @mib1185
/tests/components/immich/ @mib1185
/homeassistant/components/imou/ @Imou-OpenPlatform
/tests/components/imou/ @Imou-OpenPlatform
/homeassistant/components/improv_ble/ @emontnemery
/tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh
@@ -2060,8 +2054,8 @@ CLAUDE.md @home-assistant/core
/tests/components/yamaha_musiccast/ @vigonotion @micha91
/homeassistant/components/yandex_transport/ @rishatik92 @devbis
/tests/components/yandex_transport/ @rishatik92 @devbis
/homeassistant/components/yardian/ @aeon-matrix
/tests/components/yardian/ @aeon-matrix
/homeassistant/components/yardian/ @h3l1o5
/tests/components/yardian/ @h3l1o5
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/homeassistant/components/yeelightsunflower/ @lindsaymarkward
+4 -2
View File
@@ -92,7 +92,8 @@ def _extract_backup(
):
ostf.tar.extractall(
path=Path(tempdir, "extracted"),
filter="tar",
members=securetar.secure_path(ostf.tar),
filter="fully_trusted",
)
backup_meta_file = Path(tempdir, "extracted", "backup.json")
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
@@ -118,7 +119,8 @@ def _extract_backup(
) as istf:
istf.extractall(
path=Path(tempdir, "homeassistant"),
filter="tar",
members=securetar.secure_path(istf),
filter="fully_trusted",
)
if restore_content.restore_homeassistant:
keep = list(KEEP_BACKUPS)
-1
View File
@@ -6,7 +6,6 @@
"lg_netcast",
"lg_soundbar",
"lg_thinq",
"lg_tv_rs232",
"webostv"
]
}
@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_CUBIC_METER,
PERCENTAGE,
UV_INDEX,
UnitOfIrradiance,
@@ -46,8 +47,6 @@ from .coordinator import (
PARALLEL_UPDATES = 1
PARTS_PER_CUBIC_METER = "p/m³"
@dataclass(frozen=True, kw_only=True)
class AccuWeatherSensorDescription(SensorEntityDescription):
@@ -82,7 +81,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="Grass",
entity_registry_enabled_default=False,
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
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]]
@@ -108,7 +107,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="Mold",
entity_registry_enabled_default=False,
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
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]]
@@ -117,7 +116,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
),
AccuWeatherSensorDescription(
key="Ragweed",
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
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: {
@@ -185,7 +184,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
),
AccuWeatherSensorDescription(
key="Tree",
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
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: {
+4 -1
View File
@@ -7,7 +7,7 @@ from homeassistant.components.select import (
PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA,
SelectEntity,
)
from homeassistant.const import CONF_NAME, CONF_OPTIONS
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -19,6 +19,9 @@ from .hub import AdsHub
DEFAULT_NAME = "ADS select"
# pylint: disable-next=home-assistant-duplicate-const
CONF_OPTIONS = "options"
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_ADS_VAR): cv.string,
+2 -1
View File
@@ -72,7 +72,8 @@ async def _resolve_attachments(
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=image_data.content_type,
mime_type=attachment.get("media_content_type")
or image_data.content_type,
path=temp_filename,
)
)
@@ -1,7 +1,7 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: each
default: any
selector:
automation_behavior:
mode: trigger
@@ -8,7 +8,6 @@ from bleak.backends.device import BLEDevice
from bleak_retry_connector import close_stale_connections_by_address
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -64,16 +63,7 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
if not ble_device:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={
"address": address,
"reason": bluetooth.async_address_reachability_diagnostics(
self.hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
f"Could not find Airthings device with address {address}"
)
self.ble_device = ble_device
@@ -54,10 +54,5 @@
"name": "Radon longterm level"
}
}
},
"exceptions": {
"device_not_found": {
"message": "Could not find Airthings device with address {address}: {reason}"
}
}
}
@@ -5,7 +5,7 @@
fields: &trigger_common_fields
behavior:
required: true
default: each
default: any
selector:
automation_behavior:
mode: trigger
@@ -1,5 +1,8 @@
"""Alexa Devices integration."""
import asyncio
import contextlib
from homeassistant.const import CONF_COUNTRY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client
@@ -43,17 +46,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def _on_http2_reauth_required() -> None:
entry.async_start_reauth(hass)
async def _cancel_http2() -> None:
http2_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await http2_task
alexa_httpx_client = httpx_client.get_async_client(
hass,
alpn_protocols=SSL_ALPN_HTTP11_HTTP2,
)
await coordinator.api.start_http2_processing(
alexa_httpx_client,
on_reauth_required=_on_http2_reauth_required,
http2_task = await coordinator.api.start_http2_processing(
alexa_httpx_client, on_reauth_required=_on_http2_reauth_required
)
entry.async_on_unload(coordinator.api.stop_http2_processing)
entry.async_on_unload(_cancel_http2)
entry.runtime_data = coordinator
@@ -39,8 +39,11 @@ async def async_setup_entry(
class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
"""Button entity for Alexa routine."""
_attr_has_entity_name = True
def __init__(self, coordinator: AmazonDevicesCoordinator, routine: str) -> None:
"""Initialize the routine button entity."""
self._coordinator = coordinator
self._routine = routine
super().__init__(
coordinator,
@@ -49,4 +52,4 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
async def async_press(self) -> None:
"""Handle button press action."""
await self.coordinator.api.call_routine(self._routine)
await self._coordinator.api.call_routine(self._routine)
@@ -204,26 +204,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def sync_media_state(self) -> None:
"""Sync media state."""
try:
await self.api.sync_media_state()
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
await self.api.sync_media_state()
async def media_state_event_handler(
self, media_state: dict[str, AmazonMediaState]
@@ -53,7 +53,7 @@ class AlexaVoiceEvent(AmazonEntity, EventEntity):
_attr_event_types = [EVENT_TYPE]
coordinator: AmazonDevicesCoordinator
_last_seen_timestamp: int = 0 # January 1, 1970 at 12:00:00 AM
_last_seen_timestamp: int | None = None
@callback
def _handle_coordinator_update(self) -> None:
@@ -71,8 +71,7 @@ class AlexaVoiceEvent(AmazonEntity, EventEntity):
)
return
if vocal_record.timestamp <= self._last_seen_timestamp:
# Discard old events that have already been processed
if vocal_record.timestamp == self._last_seen_timestamp:
return
self._last_seen_timestamp = vocal_record.timestamp
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.0"]
"requirements": ["aioamazondevices==13.8.0"]
}
@@ -1,7 +1,8 @@
"""Media player platform for Alexa Devices."""
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from typing import Any, Final
from aioamazondevices.structures import (
AmazonMediaControls,
@@ -37,6 +38,18 @@ STANDARD_SUPPORTED_FEATURES = (
)
@dataclass(frozen=True, kw_only=True)
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
"""Describes an Alexa Devices media player entity."""
MEDIA_PLAYERS: Final = (
AmazonDevicesMediaPlayerEntityDescription(
key="media",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
@@ -56,10 +69,9 @@ async def async_setup_entry(
continue
known_devices.add(serial_num)
new_entities.append(
AlexaDevicesMediaPlayer(
coordinator, serial_num, MediaPlayerEntityDescription(key="media")
)
new_entities.extend(
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
for description in MEDIA_PLAYERS
)
if new_entities:
@@ -73,6 +85,8 @@ async def async_setup_entry(
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
"""Representation of an Alexa device media player."""
entity_description: AmazonDevicesMediaPlayerEntityDescription
_attr_name = None # Uses the device name
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_volume_step = 0.05
@@ -81,7 +95,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
self,
coordinator: AmazonDevicesCoordinator,
serial_num: str,
description: MediaPlayerEntityDescription,
description: AmazonDevicesMediaPlayerEntityDescription,
) -> None:
"""Initialize."""
self._prev_volume: int | None = None
@@ -142,11 +156,9 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
@property
def is_volume_muted(self) -> bool | None:
"""Return True if the volume is muted."""
if not self.volume_state or self.volume_state.volume is None:
if not self.volume_state:
return None
# is_muted is True when Alexa has muted the device
# volume == 0 is where we have muted by setting volume to 0
return self.volume_state.is_muted or self.volume_state.volume == 0
return self.volume_state.volume == 0
@property
def media_title(self) -> str | None:
@@ -200,7 +212,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
@property
def media_content_type(self) -> MediaType | None:
"""Content type — tells HA what kind of media is playing."""
if self.state in (MediaPlayerState.PLAYING, MediaPlayerState.PAUSED):
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
return MediaType.MUSIC
return None
@@ -213,8 +225,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
**kwargs: Any,
) -> None:
"""Play a piece of media."""
provider = media_type.value if isinstance(media_type, MediaType) else media_type
await self.async_call_alexa_music(media_id, provider)
await self.async_call_alexa_music(media_id, media_type)
@alexa_api_call
async def async_call_alexa_music(
@@ -248,20 +259,12 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
return
if mute:
self._prev_volume = self.volume_state.volume
await self.async_set_volume_level(0)
return
if self.volume_state.is_muted and self._prev_volume is None:
# is muted by Alexa which we can see but not control
# when muted this way, volume is still set
# changing volume will unmute
# if HA set volume to 0 then Alexa muted we just default to 30%
self._prev_volume = self.volume_state.volume or 30
if self._prev_volume is None:
return
target_volume = self._prev_volume
target_volume = 0
else:
if self._prev_volume is None:
return
target_volume = self._prev_volume
await self.async_set_volume_level(target_volume / 100)
self._prev_volume = None
@alexa_api_call
async def _send_media_command(self, command: AmazonMediaControls) -> None:
@@ -125,9 +125,6 @@
},
"invalid_sound_value": {
"message": "Invalid sound {sound} specified"
},
"unknown_exception": {
"message": "Unknown error occurred: {error}"
}
},
"selector": {
+16 -41
View File
@@ -5,12 +5,8 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import labs, websocket_api
from homeassistant.components.hassio import HassioNotReadyError
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.start import async_at_started
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -53,7 +49,6 @@ CONFIG_SCHEMA = vol.Schema(
)
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
LABS_SNAPSHOT_FEATURE = "snapshots"
@@ -62,39 +57,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the analytics integration."""
analytics_config = config.get(DOMAIN, {})
snapshots_url: str | None = None
if CONF_SNAPSHOTS_URL in analytics_config:
await labs.async_update_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
)
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
else:
snapshots_url = None
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
websocket_api.async_register_command(hass, websocket_analytics)
websocket_api.async_register_command(hass, websocket_analytics_preferences)
hass.http.register_view(AnalyticsDevicesView)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Analytics from a config entry."""
snapshots_url = hass.data[_DATA_SNAPSHOTS_URL]
analytics = Analytics(hass, snapshots_url)
try:
await analytics.load()
except HassioNotReadyError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="supervisor_not_ready",
) from err
# Load stored data
await analytics.load()
started = False
@@ -106,8 +80,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if started:
await analytics.async_schedule()
async def start_schedule(hass: HomeAssistant) -> None:
"""Start the send schedule once Home Assistant has started."""
async def start_schedule(_event: Event) -> None:
"""Start the send schedule after the started event."""
nonlocal started
started = True
await analytics.async_schedule()
@@ -115,7 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
labs.async_subscribe_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
)
async_at_started(hass, start_schedule)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
websocket_api.async_register_command(hass, websocket_analytics)
websocket_api.async_register_command(hass, websocket_analytics_preferences)
hass.http.register_view(AnalyticsDevicesView)
hass.data[DATA_COMPONENT] = analytics
return True
@@ -130,9 +109,7 @@ def websocket_analytics(
msg: dict[str, Any],
) -> None:
"""Return analytics preferences."""
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
return
analytics = hass.data[DATA_COMPONENT]
connection.send_result(
msg["id"],
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
@@ -153,10 +130,8 @@ async def websocket_analytics_preferences(
msg: dict[str, Any],
) -> None:
"""Update analytics preferences."""
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
return
preferences = msg[ATTR_PREFERENCES]
analytics = hass.data[DATA_COMPONENT]
await analytics.save_preferences(preferences)
await analytics.async_schedule()
@@ -299,8 +299,12 @@ class Analytics:
self._data = AnalyticsData.from_dict(stored)
if self.supervisor and not self.onboarded:
# This may raise HassioNotReadyError if Supervisor was unreachable.
# The caller is responsible for handling this and triggering a retry.
# This may raise HassioNotReadyError if Supervisor was unreachable
# during setup of the Supervisor integration. That will fail setup
# of this integration. However there is no better option at this time
# since we need to get the diagnostic setting from Supervisor to correctly
# setup this integration and we can't raise ConfigEntryNotReady to
# trigger a retry from async_setup.
supervisor_info = hassio.get_supervisor_info(self._hass)
# User have not configured analytics, get this setting from the supervisor
@@ -345,10 +349,10 @@ class Analytics:
await self._save()
if self.supervisor:
# Try to pull Supervisor information, but don't fail if some or all
# of it is unavailable due to setup failures in the hassio integration.
with contextlib.suppress(hassio.HassioNotReadyError):
supervisor_info = hassio.get_supervisor_info(hass)
# get_supervisor_info was called during setup so we can't get here
# if it raised. The others may raise HassioNotReadyError if only some
# data was successfully fetched from Supervisor
supervisor_info = hassio.get_supervisor_info(hass)
with contextlib.suppress(hassio.HassioNotReadyError):
operating_system_info = hassio.get_os_info(hass)
with contextlib.suppress(hassio.HassioNotReadyError):
@@ -1,19 +0,0 @@
"""Config flow for Analytics integration."""
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import DOMAIN
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Analytics."""
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="Analytics", data={})
@@ -3,7 +3,6 @@
"name": "Analytics",
"after_dependencies": ["energy", "hassio", "recorder"],
"codeowners": ["@home-assistant/core"],
"config_flow": true,
"dependencies": ["api", "websocket_api", "http"],
"documentation": "https://www.home-assistant.io/integrations/analytics",
"integration_type": "system",
@@ -15,6 +14,5 @@
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
}
},
"quality_scale": "internal",
"single_config_entry": true
"quality_scale": "internal"
}
@@ -1,9 +1,4 @@
{
"exceptions": {
"supervisor_not_ready": {
"message": "Supervisor was not ready during setup, will retry"
}
},
"preview_features": {
"snapshots": {
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
+3 -2
View File
@@ -4,6 +4,7 @@ import base64
from collections import deque
from collections.abc import AsyncIterator, Callable, Iterable
from dataclasses import dataclass, field
from datetime import UTC, datetime
import json
from mimetypes import guess_file_type
from pathlib import Path
@@ -113,7 +114,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util, slugify
from homeassistant.util import slugify
from homeassistant.util.json import JsonArrayType, JsonObjectType
from .const import (
@@ -371,7 +372,7 @@ def _convert_content( # noqa: C901
)
if (
content.native.container is not None
and content.native.container.expires_at > dt_util.utcnow()
and content.native.container.expires_at > datetime.now(UTC)
):
container_id = content.native.container.id
@@ -38,13 +38,11 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import AppleTvConfigEntry, AppleTVManager
from .browse_media import build_app_list
from .const import DOMAIN
from .entity import AppleTVEntity
_LOGGER = logging.getLogger(__name__)
@@ -128,6 +126,7 @@ class AppleTvMediaPlayer(
@callback
def async_device_connected(self, atv: AppleTV) -> None:
"""Handle when connection is made to device."""
# NB: Do not use _is_feature_available here as it only works when playing
if atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates):
atv.push_updater.listener = self
atv.push_updater.start()
@@ -353,41 +352,21 @@ class AppleTvMediaPlayer(
media_id = async_process_play_media_url(self.hass, play_item.url)
media_type = MediaType.MUSIC
use_stream_file = self._is_feature_available(FeatureName.StreamFile) and (
if self._is_feature_available(FeatureName.StreamFile) and (
media_type == MediaType.MUSIC or await is_streamable(media_id)
)
try:
if use_stream_file:
_LOGGER.debug("Streaming %s via RAOP", media_id)
await self.atv.stream.stream_file(media_id)
elif self._is_feature_available(FeatureName.PlayUrl) and (
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
):
_LOGGER.debug("Playing %s via AirPlay", media_id)
await self.atv.stream.play_url(media_id)
else:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="streaming_not_supported",
)
except exceptions.NotSupportedError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="streaming_not_supported",
) from ex
except (
exceptions.BlockedStateError,
exceptions.ConnectionLostError,
exceptions.InvalidStateError,
exceptions.OperationTimeoutError,
exceptions.PlaybackError,
exceptions.ProtocolError,
) as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="stream_failed",
) from ex
):
_LOGGER.debug("Streaming %s via RAOP", media_id)
await self.atv.stream.stream_file(media_id)
elif self._is_feature_available(FeatureName.PlayUrl) and (
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
):
_LOGGER.debug("Playing %s via AirPlay", media_id)
await self.atv.stream.play_url(media_id)
else:
_LOGGER.error(
"Media streaming is not possible with current configuration for %s",
media_id,
)
@property
def media_image_hash(self) -> str | None:
@@ -481,7 +460,7 @@ class AppleTvMediaPlayer(
def _is_feature_available(self, feature: FeatureName) -> bool:
"""Return if a feature is available."""
if self.atv:
if self.atv and self._playing:
return self.atv.features.in_state(FeatureState.Available, feature)
return False
@@ -81,12 +81,6 @@
},
"not_connected": {
"message": "Apple TV is not connected"
},
"stream_failed": {
"message": "Failed to stream media to the Apple TV"
},
"streaming_not_supported": {
"message": "Streaming the requested media is not supported"
}
},
"options": {
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["apprise"],
"quality_scale": "legacy",
"requirements": ["apprise==1.11.0"]
"requirements": ["apprise==1.9.1"]
}
+2 -3
View File
@@ -4,7 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/arwn",
"iot_class": "local_push",
"quality_scale": "legacy",
"requirements": ["arwn-client==0.2.1"]
"iot_class": "local_polling",
"quality_scale": "legacy"
}
+121 -80
View File
@@ -3,26 +3,113 @@
import logging
from typing import Any
from arwn_client import parse_message
from homeassistant.components import mqtt
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify
from homeassistant.util.json import json_loads_object
_LOGGER = logging.getLogger(__name__)
DOMAIN = "arwn"
DATA_ARWN = "arwn"
TOPIC = "arwn/#"
def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | None:
"""Given a topic, dynamically create the right sensor type.
Async friendly.
"""
parts = topic.split("/")
unit = payload.get("units", "")
domain = parts[1]
if domain == "temperature":
name = parts[2]
if unit == "F":
unit = UnitOfTemperature.FAHRENHEIT
else:
unit = UnitOfTemperature.CELSIUS
return [
ArwnSensor(
topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE
)
]
if domain == "moisture":
name = f"{parts[2]} Moisture"
return [ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent")]
if domain == "rain":
if len(parts) >= 3 and parts[2] == "today":
return [
ArwnSensor(
topic,
"Rain Since Midnight",
"since_midnight",
UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
)
]
return [
ArwnSensor(
topic + "/total",
"Total Rainfall",
"total",
unit,
device_class=SensorDeviceClass.PRECIPITATION,
),
ArwnSensor(
topic + "/rate",
"Rainfall Rate",
"rate",
unit,
device_class=SensorDeviceClass.PRECIPITATION,
),
]
if domain == "barometer":
return [
ArwnSensor(topic, "Barometer", "pressure", unit, "mdi:thermometer-lines")
]
if domain == "wind":
return [
ArwnSensor(
topic + "/speed",
"Wind Speed",
"speed",
unit,
device_class=SensorDeviceClass.WIND_SPEED,
),
ArwnSensor(
topic + "/gust",
"Wind Gust",
"gust",
unit,
device_class=SensorDeviceClass.WIND_SPEED,
),
ArwnSensor(
topic + "/dir",
"Wind Direction",
"direction",
DEGREE,
"mdi:compass",
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
]
return None
def _slug(name: str) -> str:
return f"sensor.arwn_{slugify(name)}"
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@@ -31,25 +118,28 @@ async def async_setup_platform(
) -> None:
"""Set up the ARWN platform."""
# Make sure MQTT integration is enabled and the client is available
if not await mqtt.async_wait_for_mqtt_client(hass):
_LOGGER.error("MQTT integration is not available")
return
@callback
def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None:
"""Process MQTT events as sensors."""
try:
event = json_loads_object(msg.payload)
device = parse_message(msg.topic, event)
except Exception: # noqa: BLE001
_LOGGER.debug(
"Failed to parse ARWN message on topic %s",
msg.topic,
exc_info=True,
)
return
"""Process events as sensors.
if device is None:
When a new event on our topic (arwn/#) is received we map it
into a known kind of sensor based on topic name. If we've
never seen this before, we keep this sensor around in a global
cache. If we have seen it before, we update the values of the
existing sensor. Either way, we push an ha state update at the
end for the new event we've seen.
This lets us dynamically incorporate sensors without any
configuration on our side.
"""
event = json_loads_object(msg.payload)
sensors = discover_sensors(msg.topic, event)
if not sensors:
return
if (store := hass.data.get(DATA_ARWN)) is None:
@@ -58,71 +148,22 @@ async def async_setup_platform(
if "timestamp" in event:
del event["timestamp"]
new_sensors: list[ArwnSensor] = []
for reading in device.readings:
if not reading.expose:
continue
unique_id = (
f"{msg.topic}/{reading.sensor_key}"
if len(device.readings) > 1
else msg.topic
)
try:
device_class = (
SensorDeviceClass(reading.device_class)
if reading.device_class
else None
)
except ValueError:
_LOGGER.debug(
"Unknown device_class=%s for sensor %s",
reading.device_class,
reading.sensor_name,
)
device_class = None
try:
state_class = (
SensorStateClass(reading.state_class)
if reading.state_class
else None
)
except ValueError:
_LOGGER.debug(
"Unknown state_class=%s for sensor %s",
reading.state_class,
reading.sensor_name,
)
state_class = None
if unique_id not in store:
sensor = ArwnSensor(
unique_id=unique_id,
name=reading.sensor_name,
state_key=reading.sensor_key,
units=reading.unit,
icon=reading.icon,
device_class=device_class,
state_class=state_class,
event=event,
)
store[unique_id] = sensor
for sensor in sensors:
if sensor.name not in store:
sensor.hass = hass
sensor.set_event(event)
store[sensor.name] = sensor
_LOGGER.debug(
"Registering sensor %(name)s => %(event)s",
{"name": reading.sensor_name, "event": event},
{"name": sensor.name, "event": event},
)
new_sensors.append(sensor)
async_add_entities((sensor,), True)
else:
_LOGGER.debug(
"Recording sensor %(name)s => %(event)s",
{"name": reading.sensor_name, "event": event},
{"name": sensor.name, "event": event},
)
store[unique_id].set_event(event)
if new_sensors:
async_add_entities(new_sensors, True)
store[sensor.name].set_event(event)
await mqtt.async_subscribe(hass, TOPIC, async_sensor_event_received, 0)
@@ -134,29 +175,29 @@ class ArwnSensor(SensorEntity):
def __init__(
self,
unique_id: str,
topic: str,
name: str,
state_key: str,
units: str,
icon: str | None = None,
device_class: SensorDeviceClass | None = None,
state_class: SensorStateClass | None = None,
event: dict[str, Any] | None = None,
) -> None:
"""Initialize the sensor."""
self.entity_id = _slug(name)
self._attr_name = name
self._attr_unique_id = unique_id
# This mqtt topic for the sensor which is its uid
self._attr_unique_id = topic
self._state_key = state_key
self._attr_native_unit_of_measurement = units
self._attr_icon = icon
self._attr_device_class = device_class
self._attr_state_class = state_class
if event is not None:
self._attr_extra_state_attributes = dict(event)
self._attr_native_value = event.get(state_key)
def set_event(self, event: dict[str, Any]) -> None:
"""Update the sensor with the most recent event."""
self._attr_extra_state_attributes = dict(event)
self._attr_native_value = event.get(self._state_key)
ev: dict[str, Any] = {}
ev.update(event)
self._attr_extra_state_attributes = ev
self._attr_native_value = ev.get(self._state_key)
self.async_write_ha_state()
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: each
default: any
selector:
automation_behavior:
mode: trigger
@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
}
+6 -13
View File
@@ -8,7 +8,6 @@ import avea
from bleak.exc import BleakError
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -67,15 +66,6 @@ def _is_avea_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool:
return AVEA_SERVICE_UUID in discovery_info.service_uuids
def _discovery_label(discovery_info: BluetoothServiceInfoBleak) -> str:
"""Return a label for a discovered Avea bulb."""
if (
name := _normalize_name(discovery_info.name)
) and name != discovery_info.address:
return f"{name} ({discovery_info.address})"
return discovery_info.address
class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Avea."""
@@ -160,7 +150,6 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in async_discovered_service_info(self.hass):
if (
@@ -176,10 +165,11 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
if self._discovery_info:
disc = self._discovery_info
label = f"{disc.name or disc.address} ({disc.address})"
data_schema = vol.Schema(
{
vol.Required(CONF_ADDRESS, default=disc.address): vol.In(
{disc.address: _discovery_label(disc)}
{disc.address: label}
)
}
)
@@ -188,7 +178,10 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
{
vol.Required(CONF_ADDRESS): vol.In(
{
service_info.address: _discovery_label(service_info)
service_info.address: (
f"{service_info.name or service_info.address}"
f" ({service_info.address})"
)
for service_info in self._discovered_devices.values()
}
),
@@ -51,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
translation_key="invalid_bucket_name",
) from err
except ValueError as err:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_endpoint_url",
+1 -4
View File
@@ -7,7 +7,7 @@
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
"invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
"invalid_endpoint_url": "[%key:component::aws_s3::exceptions::invalid_endpoint_url::message%]"
"invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
},
"step": {
"user": {
@@ -48,9 +48,6 @@
},
"invalid_credentials": {
"message": "Bucket cannot be accessed using provided combination of access key ID and secret access key."
},
"invalid_endpoint_url": {
"message": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
}
}
}
+3 -16
View File
@@ -11,7 +11,7 @@ from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, LocalBackupAgent, OnProgressCallback
from .const import DOMAIN, LOGGER
from .models import AgentBackup, BackupNotFound, InvalidBackupFilename
from .models import AgentBackup, BackupNotFound
from .util import read_backup, suggested_filename
@@ -54,13 +54,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
try:
backup = read_backup(backup_path)
backups[backup.backup_id] = (backup, backup_path)
except (
OSError,
TarError,
json.JSONDecodeError,
KeyError,
InvalidBackupFilename,
) as err:
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
return backups
@@ -128,14 +122,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
def get_new_backup_path(self, backup: AgentBackup) -> Path:
"""Return the local path to a new backup."""
candidate = self._backup_dir / suggested_filename(backup)
# suggested_filename does not strip separators; refuse paths that would
# land outside the backup directory.
if candidate.parent != self._backup_dir:
raise InvalidBackupFilename(
f"Refusing to write outside {self._backup_dir}: {candidate}"
)
return candidate
return self._backup_dir / suggested_filename(backup)
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Delete a backup file."""
+1 -7
View File
@@ -1978,13 +1978,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
try:
backup = await async_add_executor_job(read_backup, temp_file)
except (
OSError,
tarfile.TarError,
json.JSONDecodeError,
KeyError,
InvalidBackupFilename,
) as err:
except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError) as err:
LOGGER.warning("Unable to parse backup %s: %s", temp_file, err)
raise
+3 -10
View File
@@ -6,7 +6,7 @@ import copy
from dataclasses import dataclass, replace
from io import BytesIO
import json
from pathlib import Path, PurePath, PureWindowsPath
from pathlib import Path, PurePath
from queue import SimpleQueue
import tarfile
import threading
@@ -34,7 +34,7 @@ from homeassistant.util.async_iterator import (
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import BUF_SIZE, LOGGER, SECURETAR_CREATE_VERSION
from .models import AddonInfo, AgentBackup, Folder, InvalidBackupFilename
from .models import AddonInfo, AgentBackup, Folder
class DecryptError(HomeAssistantError):
@@ -109,13 +109,6 @@ def read_backup(backup_path: Path) -> AgentBackup:
extra_metadata = cast(dict[str, bool | str], data.get("extra", {}))
date = extra_metadata.get("supervisor.backup_request_date", data["date"])
name = cast(str, data["name"])
# The name is used to derive the on-disk filename via suggested_filename;
# reject anything that could escape the backup directory.
safe_name = PureWindowsPath(name).name
if safe_name != name or name in ("", ".", ".."):
raise InvalidBackupFilename(f"Invalid backup name: {name!r}")
return AgentBackup(
addons=addons,
backup_id=cast(str, data["slug"]),
@@ -125,7 +118,7 @@ def read_backup(backup_path: Path) -> AgentBackup:
folders=folders,
homeassistant_included=homeassistant_included,
homeassistant_version=homeassistant_version,
name=name,
name=cast(str, data["name"]),
protected=cast(bool, data.get("protected", False)),
size=backup_path.stat().st_size,
)
@@ -1,7 +1,7 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: each
default: any
selector:
automation_behavior:
mode: trigger
+6 -5
View File
@@ -6,6 +6,7 @@ from blebox_uniapi.box import Box
from blebox_uniapi.error import Error
from blebox_uniapi.session import ApiHost
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -17,9 +18,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DEFAULT_SETUP_TIMEOUT
from .coordinator import BleBoxConfigEntry, BleBoxCoordinator
from .helpers import get_maybe_authenticated_session
type BleBoxConfigEntry = ConfigEntry[Box]
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
@@ -33,6 +35,8 @@ PLATFORMS = [
Platform.UPDATE,
]
PARALLEL_UPDATES = 0
async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bool:
"""Set up BleBox devices from a config entry."""
@@ -54,10 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bo
_LOGGER.error("Identify failed at %s:%d (%s)", api_host.host, api_host.port, ex)
raise ConfigEntryNotReady from ex
coordinator = BleBoxCoordinator(hass, entry, product)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
entry.runtime_data = product
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -11,11 +11,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
PARALLEL_UPDATES = 0
BINARY_SENSOR_TYPES = (
BinarySensorEntityDescription(
key="moisture",
@@ -30,27 +27,23 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
coordinator = config_entry.runtime_data
entities = [
BleBoxBinarySensorEntity(coordinator, feature, description)
for feature in coordinator.box.features.get("binary_sensors", [])
BleBoxBinarySensorEntity(feature, description)
for feature in config_entry.runtime_data.features.get("binary_sensors", [])
for description in BINARY_SENSOR_TYPES
if description.key == feature.device_class
]
async_add_entities(entities)
async_add_entities(entities, True)
class BleBoxBinarySensorEntity(BleBoxEntity[BinarySensorFeature], BinarySensorEntity):
"""Representation of a BleBox binary sensor feature."""
def __init__(
self,
coordinator: BleBoxCoordinator,
feature: BinarySensorFeature,
description: BinarySensorEntityDescription,
self, feature: BinarySensorFeature, description: BinarySensorEntityDescription
) -> None:
"""Initialize a BleBox binary sensor feature."""
super().__init__(coordinator, feature)
super().__init__(feature)
self.entity_description = description
@property
+5 -13
View File
@@ -7,11 +7,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
from .util import blebox_command
PARALLEL_UPDATES = 1
async def async_setup_entry(
@@ -20,22 +16,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox button entry."""
coordinator = config_entry.runtime_data
entities = [
BleBoxButtonEntity(coordinator, feature)
for feature in coordinator.box.features.get("buttons", [])
BleBoxButtonEntity(feature)
for feature in config_entry.runtime_data.features.get("buttons", [])
]
async_add_entities(entities)
async_add_entities(entities, True)
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
"""Representation of BleBox buttons."""
def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
) -> None:
def __init__(self, feature: blebox_uniapi.button.Button) -> None:
"""Initialize a BleBox button feature."""
super().__init__(coordinator, feature)
super().__init__(feature)
self._attr_icon = self.get_icon()
def get_icon(self) -> str | None:
@@ -52,7 +45,6 @@ class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity
return "mdi:arrow-down-circle"
return None
@blebox_command
async def async_press(self) -> None:
"""Handle the button press."""
await self._feature.set()
+5 -8
View File
@@ -1,5 +1,6 @@
"""BleBox climate entity."""
from datetime import timedelta
from typing import Any
import blebox_uniapi.climate
@@ -16,9 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
from .util import blebox_command
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(seconds=5)
BLEBOX_TO_HVACMODE = {
0: HVACMode.OFF,
@@ -40,12 +40,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox climate entity."""
coordinator = config_entry.runtime_data
entities = [
BleBoxClimateEntity(coordinator, feature)
for feature in coordinator.box.features.get("climates", [])
BleBoxClimateEntity(feature)
for feature in config_entry.runtime_data.features.get("climates", [])
]
async_add_entities(entities)
async_add_entities(entities, True)
class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity):
@@ -109,7 +108,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
"""Return the desired thermostat temperature."""
return self._feature.desired
@blebox_command
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the climate entity mode."""
if hvac_mode in [HVACMode.HEAT, HVACMode.COOL]:
@@ -118,7 +116,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
await self._feature.async_off()
@blebox_command
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the thermostat temperature."""
value = kwargs[ATTR_TEMPERATURE]
+48 -88
View File
@@ -33,14 +33,23 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
STEP_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
vol.Inclusive(CONF_USERNAME, "auth"): str,
vol.Inclusive(CONF_PASSWORD, "auth"): str,
}
)
def create_schema(previous_input=None):
"""Create a schema with given values as default."""
if previous_input is not None:
host = previous_input[CONF_HOST]
port = previous_input[CONF_PORT]
else:
host = DEFAULT_HOST
port = DEFAULT_PORT
return vol.Schema(
{
vol.Required(CONF_HOST, default=host): str,
vol.Required(CONF_PORT, default=port): int,
vol.Inclusive(CONF_USERNAME, "auth"): str,
vol.Inclusive(CONF_PASSWORD, "auth"): str,
}
)
LOG_MSG = {
@@ -60,44 +69,18 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
self.device_config: dict[str, Any] = {}
def handle_step_exception(
self, exception, schema, host, port, message_id, log_fn, step_id
self, step, exception, schema, host, port, message_id, log_fn
):
"""Handle step exceptions."""
log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception)
return self.async_show_form(
step_id=step_id,
step_id="user",
data_schema=schema,
errors={"base": message_id},
description_placeholders={"address": f"{host}:{port}"},
)
async def _async_from_host_or_form(
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
"""Try to connect to the device; return product or an error form."""
schema = self.add_suggested_values_to_schema(STEP_SCHEMA, user_input)
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
try:
return await Box.async_from_host(api_host), None
except UnsupportedBoxVersion as ex:
return None, self.handle_step_exception(
ex, schema, host, port, UNSUPPORTED_VERSION, _LOGGER.debug, step_id
)
except UnauthorizedRequest as ex:
return None, self.handle_step_exception(
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error, step_id
)
except Error as ex:
return None, self.handle_step_exception(
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning, step_id
)
except RuntimeError as ex:
return None, self.handle_step_exception(
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
@@ -162,11 +145,12 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle initial user-triggered config step."""
hass = self.hass
schema = create_schema(user_input)
if user_input is None:
return self.async_show_form(
step_id="user",
data_schema=STEP_SCHEMA,
data_schema=schema,
errors={},
description_placeholders={},
)
@@ -189,60 +173,36 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
api_host = ApiHost(
host, port, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
)
product, error = await self._async_from_host_or_form(
api_host, user_input, step_id="user"
)
if error is not None:
return error
assert product is not None
try:
product = await Box.async_from_host(api_host)
except UnsupportedBoxVersion as ex:
return self.handle_step_exception(
"user",
ex,
schema,
host,
port,
UNSUPPORTED_VERSION,
_LOGGER.debug,
)
except UnauthorizedRequest as ex:
return self.handle_step_exception(
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error
)
except Error as ex:
return self.handle_step_exception(
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning
)
except RuntimeError as ex:
return self.handle_step_exception(
"user", ex, schema, host, port, UNKNOWN, _LOGGER.error
)
# Check if configured but IP changed since
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=product.name, data=user_input)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of a BleBox device."""
reconfigure_entry = self._get_reconfigure_entry()
if user_input is None:
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_SCHEMA, reconfigure_entry.data
),
)
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
username = user_input.get(CONF_USERNAME)
password = user_input.get(CONF_PASSWORD)
websession = get_maybe_authenticated_session(self.hass, password, username)
api_host = ApiHost(
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
)
product, error = await self._async_from_host_or_form(
api_host, user_input, step_id="reconfigure"
)
if error is not None:
return error
assert product is not None
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
self._abort_if_unique_id_mismatch()
data_updates: dict[str, Any] = {CONF_HOST: host, CONF_PORT: port}
if username is not None:
data_updates[CONF_USERNAME] = username
if password is not None:
data_updates[CONF_PASSWORD] = password
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates=data_updates,
)
@@ -1,48 +0,0 @@
"""DataUpdateCoordinator for BleBox devices."""
from datetime import timedelta
import logging
from blebox_uniapi.box import Box
from blebox_uniapi.error import Error
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type BleBoxConfigEntry = ConfigEntry[BleBoxCoordinator]
class BleBoxCoordinator(DataUpdateCoordinator[None]):
"""Coordinator for a single BleBox device."""
config_entry: BleBoxConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: BleBoxConfigEntry, box: Box
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(seconds=5),
)
self.box = box
async def _async_update_data(self) -> None:
"""Fetch data from the BleBox device."""
try:
await self.box.async_update_data()
except Error as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
+5 -19
View File
@@ -17,11 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
from .util import blebox_command
PARALLEL_UPDATES = 1
BLEBOX_TO_COVER_DEVICE_CLASSES = {
"gate": CoverDeviceClass.GATE,
@@ -63,22 +59,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
coordinator = config_entry.runtime_data
entities = [
BleBoxCoverEntity(coordinator, feature)
for feature in coordinator.box.features.get("covers", [])
BleBoxCoverEntity(feature)
for feature in config_entry.runtime_data.features.get("covers", [])
]
async_add_entities(entities)
async_add_entities(entities, True)
class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
"""Representation of a BleBox cover feature."""
def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.cover.Cover
) -> None:
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
"""Initialize a BleBox cover feature."""
super().__init__(coordinator, feature)
super().__init__(feature)
self._attr_supported_features = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
)
@@ -142,40 +135,33 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
"""Return whether cover is closed."""
return self._is_state(CoverState.CLOSED)
@blebox_command
async def async_open_cover(self, **kwargs: Any) -> None:
"""Fully open the cover position."""
await self._feature.async_open()
@blebox_command
async def async_close_cover(self, **kwargs: Any) -> None:
"""Fully close the cover position."""
await self._feature.async_close()
@blebox_command
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Fully open the cover tilt."""
position = 50 if self._feature.is_tilt_180 else 0
await self._feature.async_set_tilt_position(position)
@blebox_command
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Fully close the cover tilt."""
# note: values are reversed
await self._feature.async_set_tilt_position(100)
@blebox_command
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Set the cover position."""
position = kwargs[ATTR_POSITION]
await self._feature.async_set_position(100 - position)
@blebox_command
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self._feature.async_stop()
@blebox_command
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Set the tilt position."""
position = kwargs[ATTR_TILT_POSITION]
+15 -5
View File
@@ -1,20 +1,23 @@
"""Base entity for the BleBox devices integration."""
import logging
from blebox_uniapi.error import Error
from blebox_uniapi.feature import Feature
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .coordinator import BleBoxCoordinator
_LOGGER = logging.getLogger(__name__)
class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
class BleBoxEntity[_FeatureT: Feature](Entity):
"""Implements a common class for entities representing a BleBox feature."""
def __init__(self, coordinator: BleBoxCoordinator, feature: _FeatureT) -> None:
def __init__(self, feature: _FeatureT) -> None:
"""Initialize a BleBox entity."""
super().__init__(coordinator)
self._feature = feature
self._attr_name = feature.full_name
self._attr_unique_id = feature.unique_id
@@ -27,3 +30,10 @@ class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
sw_version=product.firmware_version,
configuration_url=f"http://{product.address}",
)
async def async_update(self) -> None:
"""Update the entity state."""
try:
await self._feature.async_update()
except Error as ex:
_LOGGER.error("Updating '%s' failed: %s", self.name, ex)
+7 -13
View File
@@ -1,5 +1,6 @@
"""BleBox light entities implementation."""
from datetime import timedelta
import logging
import math
from typing import Any
@@ -23,13 +24,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
from .util import blebox_command
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(seconds=5)
async def async_setup_entry(
@@ -38,12 +37,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
coordinator = config_entry.runtime_data
entities = [
BleBoxLightEntity(coordinator, feature)
for feature in coordinator.box.features.get("lights", [])
BleBoxLightEntity(feature)
for feature in config_entry.runtime_data.features.get("lights", [])
]
async_add_entities(entities)
async_add_entities(entities, True)
COLOR_MODE_MAP = {
@@ -63,11 +61,9 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
_attr_min_color_temp_kelvin = LIGHT_MIN_KELVINS
_attr_max_color_temp_kelvin = LIGHT_MAX_KELVINS
def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.light.Light
) -> None:
def __init__(self, feature: blebox_uniapi.light.Light) -> None:
"""Initialize a BleBox light."""
super().__init__(coordinator, feature)
super().__init__(feature)
if feature.effect_list:
self._attr_supported_features = LightEntityFeature.EFFECT
@@ -169,7 +165,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
return None
return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbww_hex))
@blebox_command
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
@@ -229,7 +224,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
" effect list."
) from exc
@blebox_command
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self._feature.async_off()
+6 -9
View File
@@ -1,6 +1,6 @@
"""BleBox sensor entities."""
from datetime import datetime
from datetime import datetime, timedelta
import blebox_uniapi.sensor
@@ -28,10 +28,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(seconds=5)
SENSOR_TYPES = (
@@ -125,14 +124,13 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
coordinator = config_entry.runtime_data
entities = [
BleBoxSensorEntity(coordinator, feature, description)
for feature in coordinator.box.features.get("sensors", [])
BleBoxSensorEntity(feature, description)
for feature in config_entry.runtime_data.features.get("sensors", [])
for description in SENSOR_TYPES
if description.key == feature.device_class
]
async_add_entities(entities)
async_add_entities(entities, True)
class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEntity):
@@ -140,12 +138,11 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
def __init__(
self,
coordinator: BleBoxCoordinator,
feature: blebox_uniapi.sensor.BaseSensor,
description: SensorEntityDescription,
) -> None:
"""Initialize a BleBox sensor feature."""
super().__init__(coordinator, feature)
super().__init__(feature)
self.entity_description = description
@property
+1 -18
View File
@@ -2,9 +2,7 @@
"config": {
"abort": {
"address_already_configured": "A BleBox device is already configured at {address}.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device identifier does not match the previously configured device."
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -13,16 +11,6 @@
},
"flow_title": "{name} ({host})",
"step": {
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::ip%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]"
},
"description": "Update the connection settings for your BleBox device.",
"title": "Reconfigure BleBox device"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::ip%]",
@@ -34,10 +22,5 @@
"title": "Set up your BleBox device"
}
}
},
"exceptions": {
"update_failed": {
"message": "An error occurred while communicating with the BleBox device: {error}"
}
}
}
+5 -8
View File
@@ -1,5 +1,6 @@
"""BleBox switch implementation."""
from datetime import timedelta
from typing import Any
import blebox_uniapi.switch
@@ -10,9 +11,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
from .util import blebox_command
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(seconds=5)
async def async_setup_entry(
@@ -21,12 +21,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox switch entity."""
coordinator = config_entry.runtime_data
entities = [
BleBoxSwitchEntity(coordinator, feature)
for feature in coordinator.box.features.get("switches", [])
BleBoxSwitchEntity(feature)
for feature in config_entry.runtime_data.features.get("switches", [])
]
async_add_entities(entities)
async_add_entities(entities, True)
class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity):
@@ -39,12 +38,10 @@ class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity
"""Return whether switch is on."""
return self._feature.is_on
@blebox_command
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
await self._feature.async_turn_on()
@blebox_command
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
await self._feature.async_turn_off()
+5 -15
View File
@@ -18,10 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import BleBoxConfigEntry
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(hours=1)
@@ -35,12 +33,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox update entry."""
coordinator = config_entry.runtime_data
entities = [
BleBoxUpdateEntity(coordinator, feature)
for feature in coordinator.box.features.get("updates", [])
BleBoxUpdateEntity(feature)
for feature in config_entry.runtime_data.features.get("updates", [])
]
async_add_entities(entities, update_before_add=True)
async_add_entities(entities, True)
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
@@ -51,16 +48,9 @@ class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
)
@property
def should_poll(self) -> bool:
"""Return True because firmware versions cannot be fetched via coordinator."""
return True
def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.update.Update
) -> None:
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
"""Initialize the update entity."""
super().__init__(coordinator, feature)
super().__init__(feature)
self._in_progress_old_version: str | None = None
self._poll_cancel: CALLBACK_TYPE | None = None
self._poll_attempts: int = 0
-29
View File
@@ -1,29 +0,0 @@
"""Utilities for BleBox."""
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, Concatenate
from blebox_uniapi.error import Error
from homeassistant.exceptions import HomeAssistantError
from .entity import BleBoxEntity
def blebox_command[_BleBoxEntityT: BleBoxEntity, **_P, _R](
func: Callable[Concatenate[_BleBoxEntityT, _P], Awaitable[_R]],
) -> Callable[Concatenate[_BleBoxEntityT, _P], Coroutine[Any, Any, _R]]:
"""Decorate BleBox calls that send commands to the device.
Catches BleBox errors and refreshes the coordinator after the command.
"""
async def handler(self: _BleBoxEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
try:
return await func(self, *args, **kwargs)
except Error as err:
raise HomeAssistantError(str(err)) from err
finally:
await self.coordinator.async_refresh()
return handler
@@ -27,7 +27,6 @@ from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME
from habluetooth import (
BaseHaRemoteScanner,
BaseHaScanner,
BluetoothReachabilityIntent,
BluetoothScannerDevice,
BluetoothScanningMode,
HaBluetoothConnector,
@@ -56,7 +55,6 @@ from . import passive_update_processor, websocket_api
from .api import (
_get_manager,
async_address_present,
async_address_reachability_diagnostics,
async_ble_device_from_address,
async_clear_address_from_match_history,
async_clear_advertisement_history,
@@ -110,14 +108,12 @@ __all__ = [
"BluetoothCallback",
"BluetoothCallbackMatcher",
"BluetoothChange",
"BluetoothReachabilityIntent",
"BluetoothScannerDevice",
"BluetoothScanningMode",
"BluetoothServiceInfo",
"BluetoothServiceInfoBleak",
"HaBluetoothConnector",
"async_address_present",
"async_address_reachability_diagnostics",
"async_ble_device_from_address",
"async_clear_address_from_match_history",
"async_clear_advertisement_history",
@@ -11,7 +11,6 @@ from typing import TYPE_CHECKING, cast
from bleak import BleakScanner
from habluetooth import (
BaseHaScanner,
BluetoothReachabilityIntent,
BluetoothScannerDevice,
BluetoothScanningMode,
HaBleakScannerWrapper,
@@ -109,14 +108,6 @@ def async_ble_device_from_address(
return _get_manager(hass).async_ble_device_from_address(address, connectable)
@hass_callback
def async_address_reachability_diagnostics(
hass: HomeAssistant, address: str, intent: BluetoothReachabilityIntent
) -> str:
"""Return a human readable explanation of why an address may be unreachable."""
return _get_manager(hass).async_address_reachability_diagnostics(address, intent)
@hass_callback
def async_scanner_devices_by_address(
hass: HomeAssistant, address: str, connectable: bool = True
@@ -20,7 +20,7 @@
"bluetooth-adapters==2.3.0",
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.16",
"habluetooth==6.8.1"
"dbus-fast==5.0.14",
"habluetooth==6.7.4"
]
}
@@ -3,7 +3,6 @@
"name": "Sony Bravia TV",
"codeowners": ["@bieniu", "@Drafteed"],
"config_flow": true,
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/braviatv",
"integration_type": "device",
"iot_class": "local_polling",
@@ -92,7 +92,7 @@ class BroadlinkRadioFrequency(BroadlinkEntity, RadioFrequencyTransmitterEntity):
"""Representation of a Broadlink RF transmitter."""
_attr_has_entity_name = True
_attr_translation_key = "rf_transmitter"
_attr_name = None
def __init__(self, device: BroadlinkDevice) -> None:
"""Initialize the entity."""
@@ -54,11 +54,6 @@
"name": "IR emitter"
}
},
"radio_frequency": {
"rf_transmitter": {
"name": "RF transmitter"
}
},
"select": {
"day_of_week": {
"name": "Day of week",
+1 -2
View File
@@ -3,7 +3,6 @@
import logging
import caldav
from caldav.lib.error import DAVError
from homeassistant.core import HomeAssistant
@@ -27,7 +26,7 @@ async def async_get_calendars(
for calendar in client.principal().calendars():
try:
supported_components = calendar.get_supported_components()
except KeyError, DAVError:
except KeyError:
needs_warning.append((str(calendar.url), calendar.name, component))
if component in ASSUMED_COMPONENTS:
@@ -66,10 +66,5 @@ async def get_cert_expiry_timestamp(
except ssl.SSLError as err:
raise ValidationFailure(err.args[0]) from err
if not cert or "notAfter" not in cert:
raise ValidationFailure(
f"No certificate expiration found for: {hostname}:{port}"
)
ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"])
return dt_util.utc_from_timestamp(ts_seconds)
+2 -2
View File
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase,
@@ -26,7 +26,7 @@ from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONF_HVAC_MODE = "hvac_mode"
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_HVAC_MODE): vol.All(
@@ -5,7 +5,7 @@
fields:
behavior: &trigger_behavior
required: true
default: each
default: any
selector:
automation_behavior:
mode: trigger
@@ -24,7 +24,6 @@ from homeassistant.components.alexa import (
entities as alexa_entities,
errors as alexa_errors,
)
from homeassistant.components.frontend import DATA_THEMES
from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
@@ -509,15 +508,6 @@ class DownloadSupportPackageView(HomeAssistantView):
"custom_integrations": custom_integrations,
}
@callback
def _get_themes_info(self, hass: HomeAssistant) -> dict[str, Any]:
"""Collect information about user-installed custom themes."""
themes: dict[str, Any] = hass.data.get(DATA_THEMES, {})
return {
"count": len(themes),
"themes": sorted(themes),
}
async def _generate_markdown(
self,
hass: HomeAssistant,
@@ -579,25 +569,6 @@ class DownloadSupportPackageView(HomeAssistantView):
)
markdown += "\n</details>\n\n"
# Add custom themes information
try:
themes_info = self._get_themes_info(hass)
except Exception: # noqa: BLE001
# Broad exception catch for robustness in support package generation
markdown += "## Custom Themes\n\n"
markdown += "Unable to collect themes information\n\n"
else:
markdown += "## Custom Themes\n\n"
markdown += f"Custom themes: {themes_info['count']}\n\n"
if themes_info["themes"]:
markdown += "<details><summary>Custom themes</summary>\n\n"
markdown += "Name\n"
markdown += "---\n"
for theme in themes_info["themes"]:
markdown += f"{theme}\n"
markdown += "\n</details>\n\n"
for domain, domain_info in domains_info.items():
domain_info_md = get_domain_table_markdown(domain_info)
markdown += (
@@ -175,6 +175,7 @@ class ConfigManagerFlowIndexView(
vol.Schema(
{
vol.Required("handler"): vol.Any(str, list),
vol.Optional("show_advanced_options", default=False): cv.boolean,
vol.Optional("entry_id"): cv.string,
},
extra=vol.ALLOW_EXTRA,
@@ -301,6 +302,7 @@ class SubentryManagerFlowIndexView(
vol.Schema(
{
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
vol.Optional("show_advanced_options", default=False): cv.boolean,
},
extra=vol.ALLOW_EXTRA,
)
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
}
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: each
default: any
selector:
automation_behavior:
mode: trigger
+1 -1
View File
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: each
default: any
selector:
automation_behavior:
mode: trigger
@@ -20,8 +20,6 @@ from denonavr.const import (
from denonavr.exceptions import (
AvrCommandError,
AvrForbiddenError,
AvrIncompleteResponseError,
AvrInvalidResponseError,
AvrNetworkError,
AvrProcessingError,
AvrTimoutError,
@@ -193,17 +191,6 @@ def async_log_errors[_DenonDeviceT: DenonDevice, **_P, _R](
self._receiver.host,
)
self._attr_available = False
except AvrInvalidResponseError, AvrIncompleteResponseError:
available = False
if self.available:
_LOGGER.warning(
(
"Denon AVR receiver at host %s returned malformed response. "
"Device is unavailable"
),
self._receiver.host,
)
self._attr_available = False
except AvrCommandError as err:
available = False
_LOGGER.error(
@@ -22,7 +22,6 @@ from .const import ( # noqa: F401
ATTR_LOCATION_NAME,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONF_CONSIDER_HOME,
CONF_NEW_DEVICE_DEFAULTS,
CONF_SCAN_INTERVAL,
@@ -36,8 +36,6 @@ DEFAULT_CONSIDER_HOME: Final = timedelta(seconds=180)
CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults"
CONF_ASSOCIATED_ZONE: Final = "associated_zone"
ATTR_ATTRIBUTES: Final = "attributes"
ATTR_BATTERY: Final = "battery"
ATTR_DEV_ID: Final = "dev_id"
@@ -12,19 +12,13 @@ from homeassistant.const import (
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_OPTIONS,
CONF_PLATFORM,
CONF_TYPE,
CONF_ZONE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.trigger import (
TriggerActionType,
TriggerInfo,
# protected, but only used for legacy triggers
_async_attach_trigger_cls,
)
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
@@ -85,18 +79,16 @@ async def async_attach_trigger(
event = zone.EVENT_ENTER
else:
event = zone.EVENT_LEAVE
zone_config = await zone.LegacyZoneTrigger.async_validate_config(
hass,
{
CONF_OPTIONS: {
CONF_ENTITY_ID: [config[CONF_ENTITY_ID]],
CONF_ZONE: config[CONF_ZONE],
CONF_EVENT: event,
}
},
)
return await _async_attach_trigger_cls(
hass, zone.LegacyZoneTrigger, "device", zone_config, action, trigger_info
zone_config = {
CONF_PLATFORM: ZONE_DOMAIN,
CONF_ENTITY_ID: config[CONF_ENTITY_ID],
CONF_ZONE: config[CONF_ZONE],
CONF_EVENT: event,
}
zone_config = await zone.async_validate_trigger_config(hass, zone_config)
return await zone.async_attach_trigger(
hass, zone_config, action, trigger_info, platform_type="device"
)
+15 -224
View File
@@ -1,8 +1,7 @@
"""Provide functionality to keep track of devices."""
import asyncio
import logging
from typing import TYPE_CHECKING, Any, final
from typing import Any, final
from propcache.api import cached_property
@@ -17,20 +16,8 @@ from homeassistant.const import (
STATE_NOT_HOME,
EntityCategory,
)
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
State,
async_get_hass_or_none,
callback,
)
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import (
DeviceInfo,
EventDeviceRegistryUpdatedData,
@@ -38,8 +25,6 @@ from homeassistant.helpers.device_registry import (
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.loader import async_suggest_report_issue
from homeassistant.util.hass_dict import HassKey
from .const import (
@@ -48,15 +33,12 @@ from .const import (
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
SourceType,
)
_LOGGER = logging.getLogger(__name__)
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
@@ -169,35 +151,11 @@ class BaseTrackerEntity(Entity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_source_type: SourceType
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
super().__init_subclass__(**kwargs)
if "battery_level" in cls.__dict__:
if cls.__module__.startswith("homeassistant.components."):
# Don't ask users to report issue for built in integrations,
# they already have issues opened on them.
return
report_issue = async_suggest_report_issue(
async_get_hass_or_none(), module=cls.__module__
)
_LOGGER.warning(
(
"%s::%s is overriding the deprecated battery_level property on "
"a subclass of BaseTrackerEntity, this will be unsupported from "
"Home Assistant 2027.7, please %s"
),
cls.__module__,
cls.__name__,
report_issue,
)
@cached_property
def battery_level(self) -> int | None:
"""Return the battery level of the device.
Percentage from 0-100.
The property is deprecated and will be removed in Home Assistant 2027.7.
"""
return None
@@ -241,38 +199,13 @@ class TrackerEntity(
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
# _attr_location_name is deprecated and will be removed in Home Assistant 2027.7
_attr_location_name: str | None = None
_attr_longitude: float | None = None
_attr_source_type: SourceType = SourceType.GPS
__active_zone: State | None = None
# If we reported setting deprecated _attr_location_name
__deprecated_attr_location_name_reported = False
__in_zones: list[str] | None = None
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
super().__init_subclass__(**kwargs)
if "location_name" in cls.__dict__:
if cls.__module__.startswith("homeassistant.components."):
# Don't ask users to report issue for built in integrations,
# they already have issues opened on them.
return
report_issue = async_suggest_report_issue(
async_get_hass_or_none(), module=cls.__module__
)
_LOGGER.warning(
(
"%s::%s is overriding the deprecated location_name property on "
"an instance of TrackerEntity, this will be unsupported from "
"Home Assistant 2027.7, please %s"
),
cls.__module__,
cls.__name__,
report_issue,
)
@cached_property
def should_poll(self) -> bool:
"""No polling for entities that have location pushed."""
@@ -288,8 +221,8 @@ class TrackerEntity(
"""Return the entity_id of zones the device is currently in.
The list may be in any order; the base class sorts it by zone radius
and discards zones which do not exist. Takes precedence over latitude
and longitude when set (including when set to an empty list).
and discards zones which do not exist. Ignored if latitude and
longitude are both set.
"""
return self._attr_in_zones
@@ -303,32 +236,7 @@ class TrackerEntity(
@cached_property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device.
The property is deprecated and will be removed in Home Assistant 2027.7.
"""
if (location_name := self._attr_location_name) is not None:
if (
not self.__deprecated_attr_location_name_reported
and not self.__class__.__module__.startswith(
"homeassistant.components."
)
):
report_issue = async_suggest_report_issue(
self.hass, module=self.__class__.__module__
)
_LOGGER.warning(
(
"%s::%s is setting the deprecated _attr_location_name attribute "
"on an instance of TrackerEntity, this will be unsupported from "
"Home Assistant 2027.7, please %s"
),
self.__class__.__module__,
self.__class__.__name__,
report_issue,
)
self.__deprecated_attr_location_name_reported = True
return location_name
"""Return a location name for the current location of the device."""
return self._attr_location_name
@cached_property
@@ -344,7 +252,11 @@ class TrackerEntity(
@callback
def _async_write_ha_state(self) -> None:
"""Calculate active zones."""
if (zones := self.in_zones) is not None:
if self.available and self.latitude is not None and self.longitude is not None:
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
elif (zones := self.in_zones) is not None:
zone_states = sorted(
(
zone_state
@@ -358,12 +270,6 @@ class TrackerEntity(
None,
)
self.__in_zones = [z.entity_id for z in zone_states]
elif (
self.available and self.latitude is not None and self.longitude is not None
):
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
else:
self.__active_zone = None
self.__in_zones = None
@@ -411,120 +317,14 @@ class BaseScannerEntity(BaseTrackerEntity):
addresses being used to identify the device.
"""
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
async def async_internal_added_to_hass(self) -> None:
"""Call when the scanner entity is added to hass."""
await super().async_internal_added_to_hass()
if not self.registry_entry:
return
self._async_read_entity_options()
async def async_internal_will_remove_from_hass(self) -> None:
"""Call when the scanner entity is about to be removed from hass."""
await super().async_internal_will_remove_from_hass()
if not self.registry_entry:
return
if self._scanner_option_associated_zone_unsub is not None:
self._scanner_option_associated_zone_unsub()
self._scanner_option_associated_zone_unsub = None
self._async_clear_associated_zone_issue()
@callback
def async_registry_entry_updated(self) -> None:
"""Run when the entity registry entry has been updated."""
self._async_read_entity_options()
@callback
def _async_read_entity_options(self) -> None:
"""Read entity options from the entity registry.
Called when the entity registry entry has been updated and before the
scanner entity is added to the state machine.
"""
assert self.registry_entry
if (scanner_options := self.registry_entry.options.get(DOMAIN)) and (
associated_zone := scanner_options.get(CONF_ASSOCIATED_ZONE)
):
new_zone = associated_zone
else:
new_zone = zone.ENTITY_ID_HOME
if new_zone == self._scanner_option_associated_zone:
return
# Tear down tracking for the previous zone.
if self._scanner_option_associated_zone_unsub is not None:
self._scanner_option_associated_zone_unsub()
self._scanner_option_associated_zone_unsub = None
self._async_clear_associated_zone_issue()
self._scanner_option_associated_zone = new_zone
# zone.home is always present so no tracking or issue handling needed.
if new_zone == zone.ENTITY_ID_HOME:
return
self._scanner_option_associated_zone_unsub = async_track_state_change_event(
self.hass, new_zone, self._async_associated_zone_state_changed
)
if self.hass.states.get(new_zone) is None:
self._async_create_associated_zone_issue()
@callback
def _async_associated_zone_state_changed(
self, event: Event[EventStateChangedData]
) -> None:
"""Open or clear the repair issue when the associated zone appears or disappears."""
if event.data["new_state"] is None:
self._async_create_associated_zone_issue()
else:
self._async_clear_associated_zone_issue()
self.async_write_ha_state()
@callback
def _async_create_associated_zone_issue(self) -> None:
"""Create a repair issue prompting the user to reconfigure the scanner."""
ir.async_create_issue(
self.hass,
DOMAIN,
self._associated_zone_issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="associated_zone_missing",
translation_placeholders={
"entity_id": self.entity_id,
"zone": self._scanner_option_associated_zone,
},
)
@callback
def _async_clear_associated_zone_issue(self) -> None:
"""Clear the associated-zone-missing repair issue if it exists."""
ir.async_delete_issue(self.hass, DOMAIN, self._associated_zone_issue_id)
@property
def _associated_zone_issue_id(self) -> str:
"""Return the issue id for the associated-zone-missing repair."""
if TYPE_CHECKING:
assert self.registry_entry
return f"associated_zone_missing_{self.registry_entry.id}"
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.is_connected is None:
return None
if not self.is_connected:
return STATE_NOT_HOME
associated_zone = self._scanner_option_associated_zone
if associated_zone == zone.ENTITY_ID_HOME:
if self.is_connected:
return STATE_HOME
if zone_state := self.hass.states.get(associated_zone):
return zone_state.name
# Configured zone has been removed; state is unknown.
return None
return STATE_NOT_HOME
@property
def is_connected(self) -> bool | None:
@@ -541,18 +341,9 @@ class BaseScannerEntity(BaseTrackerEntity):
if not self.is_connected:
return attr
associated_zone = self._scanner_option_associated_zone
# If the configured zone has been removed, in_zones stays empty so the
# attribute does not claim membership in a zone that no longer exists.
if (
associated_zone != zone.ENTITY_ID_HOME
and self.hass.states.get(associated_zone) is None
):
return attr
attr[ATTR_IN_ZONES] = [
associated_zone,
*zone.async_get_enclosing_zones(self.hass, associated_zone),
zone.ENTITY_ID_HOME,
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
]
return attr
@@ -38,9 +38,6 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import (
async_create_platform_config_not_supported_issue,
)
from homeassistant.helpers.event import (
async_track_time_interval,
async_track_utc_time_change,
@@ -382,8 +379,8 @@ async def async_extract_config(
if platform.type == PLATFORM_TYPE_LEGACY:
legacy.append(platform)
else:
async_create_platform_config_not_supported_issue(
hass, platform.name, DOMAIN
raise ValueError(
f"Unable to determine type for {platform.name}: {platform.type}"
)
return legacy
@@ -44,12 +44,6 @@
}
}
},
"issues": {
"associated_zone_missing": {
"description": "The scanner entity `{entity_id}` is associated with the zone `{zone}`, but that zone has been removed.\n\nTo fix this, reconfigure the scanner to use a different zone or recreate the missing zone.",
"title": "Scanner is associated with a removed zone"
}
},
"services": {
"see": {
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
+1 -1
View File
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: each
default: any
selector:
automation_behavior:
mode: trigger
@@ -50,7 +50,7 @@ class DuckDnsUpdateCoordinator(DataUpdateCoordinator[None]):
"""Update Duck DNS."""
retry_after = BACKOFF_INTERVALS[
min(self.failed, len(BACKOFF_INTERVALS) - 1)
min(self.failed, len(BACKOFF_INTERVALS))
].total_seconds()
try:
+2 -12
View File
@@ -86,6 +86,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
"""Fetch node data from the Duco box."""
try:
nodes = await self.client.async_get_nodes()
lan_info = await self.client.async_get_lan_info()
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
@@ -99,18 +100,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
translation_placeholders={"error": repr(err)},
) from err
# LAN info only backs the diagnostic RSSI sensor, so failures on this
# supplemental endpoint, including connection failures, should not make
# the primary node entities unavailable.
rssi_wifi = self.data.rssi_wifi if self.data else None
try:
lan_info = await self.client.async_get_lan_info()
except DucoError as err:
_LOGGER.debug("Could not fetch Duco LAN info", exc_info=err)
else:
rssi_wifi = lan_info.rssi_wifi
return DucoData(
nodes={node.node_id: node for node in nodes},
rssi_wifi=rssi_wifi,
rssi_wifi=lan_info.rssi_wifi,
)
+17 -17
View File
@@ -64,23 +64,23 @@
"ventilation_state": {
"name": "Ventilation state",
"state": {
"aut1": "AUT1",
"aut2": "AUT2",
"aut3": "AUT3",
"auto": "AUTO",
"cnt1": "CNT1",
"cnt2": "CNT2",
"cnt3": "CNT3",
"empt": "EMPT",
"man1": "MAN1",
"man1x2": "MAN1x2",
"man1x3": "MAN1x3",
"man2": "MAN2",
"man2x2": "MAN2x2",
"man2x3": "MAN2x3",
"man3": "MAN3",
"man3x2": "MAN3x2",
"man3x3": "MAN3x3"
"aut1": "Automatic boost (15 min)",
"aut2": "Automatic boost (30 min)",
"aut3": "Automatic boost (45 min)",
"auto": "Automatic",
"cnt1": "Continuous low speed",
"cnt2": "Continuous medium speed",
"cnt3": "Continuous high speed",
"empt": "Empty house",
"man1": "Manual low speed (15 min)",
"man1x2": "Manual low speed (30 min)",
"man1x3": "Manual low speed (45 min)",
"man2": "Manual medium speed (15 min)",
"man2x2": "Manual medium speed (30 min)",
"man2x3": "Manual medium speed (45 min)",
"man3": "Manual high speed (15 min)",
"man3x2": "Manual high speed (30 min)",
"man3x3": "Manual high speed (45 min)"
}
}
}
@@ -100,7 +100,7 @@ class DwdWeatherWarningsSensor(
if warnings is None:
return []
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
now = datetime.now(UTC)
return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now]
@property
@@ -294,9 +294,6 @@
"vacuum_raw_get_positions_not_supported": {
"message": "Retrieving the positions of the chargers and the device itself is not supported"
},
"vacuum_send_command_not_supported": {
"message": "The {command} command is not supported by {name}"
},
"vacuum_send_command_params_dict": {
"message": "Params must be a dictionary and not a list"
},
+3 -2
View File
@@ -353,10 +353,11 @@ class EcovacsVacuum(
if self._capability.clean.action.area is None:
info = self._device.device_info
name = info.get("nick", info["name"])
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="vacuum_send_command_not_supported",
translation_placeholders={"command": command, "name": name},
translation_key="vacuum_send_command_area_not_supported",
translation_placeholders={"name": name},
)
if command == "spot_area":
@@ -106,7 +106,7 @@ async def async_migrate_entry(
new_options = {**config_entry.options}
if config_entry.minor_version < 2:
# Add defaults only if they're not already present
# Add defaults only if theyre not already present
if "stt_auto_language" not in new_options:
new_options["stt_auto_language"] = False
if "stt_model" not in new_options:
@@ -221,7 +221,6 @@ def update_listeners(hass: HomeAssistant, entry: EnergyIDConfigEntry) -> None:
):
try:
value = float(current_state.state)
# pylint: disable-next=home-assistant-enforce-utcnow
timestamp = current_state.last_updated or dt.datetime.now(dt.UTC)
client.get_or_create_sensor(energyid_key).update(value, timestamp)
except ValueError, TypeError:
@@ -1,37 +0,0 @@
"""Envertech EVT800 integration."""
from pyenvertechevt800 import EnvertechEVT800
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
from .const import PLATFORMS
from .coordinator import EnvertechEVT800Coordinator
type EnvertechEVT800ConfigEntry = ConfigEntry[EnvertechEVT800Coordinator]
async def async_setup_entry(
hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry
) -> bool:
"""Set up Envertech EVT800 from a config entry."""
evt800 = EnvertechEVT800(entry.data[CONF_IP_ADDRESS], entry.data[CONF_PORT])
evt800.start()
coordinator = EnvertechEVT800Coordinator(hass, evt800, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,60 +0,0 @@
"""Config flow for the ENVERTECH EVT800 integration."""
from typing import Any
from pyenvertechevt800 import EnvertechEVT800
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_TYPE
from homeassistant.helpers import config_validation as cv
from .const import DEFAULT_PORT, DOMAIN, TYPE_TCP_SERVER_MODE
SCHEMA_DEVICE = vol.Schema(
{
vol.Required(CONF_IP_ADDRESS): cv.string,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
}
)
class EnvertechFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for Envertech EVT800."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""First step in config flow."""
errors: dict[str, str] = {}
if user_input is not None:
ip_address = user_input[CONF_IP_ADDRESS]
port = user_input[CONF_PORT]
self._async_abort_entries_match(
{
CONF_IP_ADDRESS: ip_address,
CONF_PORT: port,
}
)
evt800 = EnvertechEVT800(ip_address, port)
can_connect = await evt800.test_connection()
if not can_connect:
errors["base"] = "cannot_connect"
if not errors:
return self.async_create_entry(
title="Envertech EVT800",
data={CONF_TYPE: TYPE_TCP_SERVER_MODE, **user_input},
)
return self.async_show_form(
step_id="user",
data_schema=SCHEMA_DEVICE,
errors=errors,
)
@@ -1,11 +0,0 @@
"""Constants for the ENVERTECH EVT800 integration."""
from homeassistant.const import Platform
DOMAIN = "envertech_evt800"
PLATFORMS = [Platform.SENSOR]
DEFAULT_PORT = 14889
TYPE_TCP_SERVER_MODE = ["TCP_SERVER"]
DEFAULT_SCAN_INTERVAL = 60
@@ -1,44 +0,0 @@
"""Coordinator for Envertech EVT800 integration."""
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
import pyenvertechevt800
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
if TYPE_CHECKING:
from . import EnvertechEVT800ConfigEntry
_LOGGER = logging.getLogger(__name__)
class EnvertechEVT800Coordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Data update coordinator for Envertech EVT800."""
config_entry: EnvertechEVT800ConfigEntry
def __init__(
self,
hass: HomeAssistant,
client: pyenvertechevt800.EnvertechEVT800,
config_entry: EnvertechEVT800ConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
config_entry=config_entry,
)
self.client = client
client.set_data_listener(self.async_set_updated_data)
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the device."""
return self.client.data
@@ -1,29 +0,0 @@
"""Envertech EVT800 entity."""
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import EnvertechEVT800Coordinator
class EnvertechEVT800Entity(CoordinatorEntity[EnvertechEVT800Coordinator]):
"""Envertech EVT800 entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: EnvertechEVT800Coordinator) -> None:
"""Initialize Envertech EVT800 entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
configuration_url=f"http://{coordinator.config_entry.data[CONF_IP_ADDRESS]}/",
manufacturer="Envertech",
model_id="EVT800",
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.coordinator.client.online

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