Compare commits

..

10 Commits

Author SHA1 Message Date
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
44 changed files with 326 additions and 803 deletions
@@ -0,0 +1,52 @@
name: Cache and install APT packages
description: >-
Wraps awalsh128/cache-apt-pkgs-action with the workarounds Home Assistant CI
needs. Removes the conflicting Microsoft apt source before any apt run, and
points the dynamic linker at the host's multiarch lib subdirectories so
shared libraries that rely on update-alternatives or postinst-managed paths
(eg libblas, liblapack pulled in by ffmpeg) stay reachable since the upstream
action does not execute postinst scripts on cache restore.
inputs:
packages:
description: Space-delimited list of apt packages to install.
required: true
version:
description: Cache version. Bump to invalidate the cache.
required: false
default: "1"
execute_install_scripts:
description: >-
Pass-through to awalsh128/cache-apt-pkgs-action. Postinst scripts are not
actually cached by the upstream action, so this is largely a no-op today.
required: false
default: "false"
runs:
using: composite
steps:
- name: Remove conflicting Microsoft apt source
shell: bash
run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
- name: Install apt packages via cache
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
with:
packages: ${{ inputs.packages }}
version: ${{ inputs.version }}
execute_install_scripts: ${{ inputs.execute_install_scripts }}
- name: Refresh dynamic linker cache
shell: bash
run: |
# awalsh128/cache-apt-pkgs-action does not run postinst scripts on
# cache restore, so update-alternatives symlinks (eg the one libblas
# creates at /usr/lib/<multiarch>/libblas.so.3) are never produced.
# Add every /usr/lib/<multiarch> subdirectory that holds shared
# libraries to the ldconfig search path so the dynamic linker still
# finds them. Use dpkg-architecture to derive the host's multiarch
# tuple so this works on non-x86_64 runners too.
multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
find "/usr/lib/${multiarch}" -mindepth 2 -maxdepth 2 \
-name '*.so.*' -printf '%h\n' \
| sort -u \
| sudo tee /etc/ld.so.conf.d/zzz-cache-apt-extras.conf > /dev/null
sudo ldconfig
@@ -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
@@ -25,7 +25,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
## Python Syntax Notes
+132 -240
View File
@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 3
CACHE_VERSION: 4
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.6"
ADDITIONAL_PYTHON_VERSIONS: "[]"
@@ -60,9 +60,7 @@ env:
# - 15.2 is the latest (as of 9 Feb 2023)
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
UV_CACHE_DIR: /tmp/uv-cache
APT_CACHE_BASE: /home/runner/work/apt
APT_CACHE_DIR: /home/runner/work/apt/cache
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
APT_CACHE_VERSION: 1
SQLALCHEMY_WARN_20: 1
PYTHONASYNCIODEBUG: 1
HASS_CI: 1
@@ -86,12 +84,13 @@ jobs:
core: ${{ steps.core.outputs.changes }}
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
integrations: ${{ steps.integrations.outputs.changes }}
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
requirements: ${{ steps.core.outputs.requirements }}
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 }}
@@ -116,10 +115,6 @@ jobs:
# Include HA_SHORT_VERSION to force the immediate creation
# of a new uv cache entry after a version bump.
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
- name: Generate partial apt restore key
id: generate_apt_cache_key
run: |
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
- name: Filter for core changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: core
@@ -242,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}"
@@ -351,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
@@ -384,80 +384,41 @@ jobs:
path: ${{ env.UV_CACHE_DIR }}
key: ${{ steps.generate-uv-key.outputs.full_key }}
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
- name: Check if apt cache exists
id: cache-apt-check
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
if: |
steps.cache-venv.outputs.cache-hit != 'true'
|| steps.cache-apt-check.outputs.cache-hit != 'true'
id: install-os-deps
if: steps.cache-venv.outputs.cache-hit != 'true'
timeout-minutes: 10
env:
APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
mkdir -p ${APT_CACHE_DIR}
mkdir -p ${APT_LIST_CACHE_DIR}
fi
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils \
libavcodec-dev \
libavdevice-dev \
libavfilter-dev \
libavformat-dev \
libavutil-dev \
libswresample-dev \
libswscale-dev \
libudev-dev
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
sudo chmod -R 755 ${APT_CACHE_BASE}
fi
- name: Save apt cache
if: |
always()
&& steps.cache-apt-check.outputs.cache-hit != 'true'
&& steps.install-os-deps.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: ./.github/actions/cache-apt-packages
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
libavcodec-dev
libavdevice-dev
libavfilter-dev
libavformat-dev
libavutil-dev
libswresample-dev
libswscale-dev
libudev-dev
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- 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
pip install "$(grep '^uv' < requirements.txt)"
uv pip install -U "pip>=25.2"
uv pip install -r requirements.txt
uv pip install -r requirements_all.txt -r requirements_test.txt
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
@@ -506,36 +467,22 @@ jobs:
&& github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
libturbojpeg
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: libturbojpeg
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
@@ -569,10 +516,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
@@ -605,10 +552,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
@@ -660,10 +607,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
@@ -711,10 +658,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
@@ -764,10 +711,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
@@ -815,10 +762,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: |
@@ -876,38 +823,26 @@ jobs:
- info
- base
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
version: ${{ env.APT_CACHE_VERSION }}
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
@@ -952,39 +887,27 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
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
@@ -1105,40 +1028,28 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libmariadb-dev-compat \
libxml2-utils
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libmariadb-dev-compat
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
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
@@ -1266,42 +1177,35 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get -y install \
postgresql-server-dev-14
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up PostgreSQL apt repository
run: sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
- name: Cache PostgreSQL development headers
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: postgresql-server-dev-14
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
@@ -1449,39 +1353,27 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
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
-1
View File
@@ -15,7 +15,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
## Python Syntax Notes
+5 -13
View File
@@ -130,25 +130,17 @@ def async_register_callback(
callback: BluetoothCallback,
match_dict: BluetoothCallbackMatcher | None,
mode: BluetoothScanningMode,
*,
scan_interval: float | None = None,
scan_duration: float | None = None,
) -> Callable[[], None]:
"""Register to receive a callback on bluetooth change.
When ``mode`` is ACTIVE and ``scan_interval`` is provided, the
matched address (taken from ``match_dict["address"]``) is registered
with habluetooth's active-scan scheduler so AUTO-mode scanners flip
on demand every ``scan_interval`` seconds for ``scan_duration``
seconds, instead of forcing continuous active scanning. Without an
address in the matcher the active-scan request is skipped; the
callback itself still fires normally.
mode is currently not used as we only support active scanning.
Passive scanning will be available in the future. The flag
is required to be present to avoid a future breaking change
when we support passive scanning.
Returns a callback that can be used to cancel the registration.
"""
return _get_manager(hass).async_register_callback(
callback, match_dict, mode, scan_interval, scan_duration
)
return _get_manager(hass).async_register_callback(callback, match_dict)
async def async_process_advertisements(
+1 -20
View File
@@ -202,9 +202,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
self,
callback: BluetoothCallback,
matcher: BluetoothCallbackMatcher | None,
mode: BluetoothScanningMode = BluetoothScanningMode.ACTIVE,
scan_interval: float | None = None,
scan_duration: float | None = None,
) -> Callable[[], None]:
"""Register a callback."""
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
@@ -219,31 +216,15 @@ class HomeAssistantBluetoothManager(BluetoothManager):
connectable = callback_matcher[CONNECTABLE]
self._callback_index.add_callback_matcher(callback_matcher)
# If the caller declared an active-scan cadence and the matcher
# targets a specific address, also wire it into habluetooth's
# active-scan scheduler so AUTO-mode scanners flip active on
# demand for this device instead of forcing continuous scanning.
cancel_active_scan: Callable[[], None] | None = None
if (
scan_interval is not None
and mode is not BluetoothScanningMode.PASSIVE
and (address := callback_matcher.get(ADDRESS)) is not None
):
cancel_active_scan = self.async_register_active_scan(
address, scan_interval, scan_duration
)
def _async_remove_callback() -> None:
self._callback_index.remove_callback_matcher(callback_matcher)
if cancel_active_scan is not None:
cancel_active_scan()
# If we have history for the subscriber, we can trigger the callback
# immediately with the last packet so the subscriber can see the
# device.
history = self._connectable_history if connectable else self._all_history
service_infos: Iterable[BluetoothServiceInfoBleak] = []
if (address := callback_matcher.get(ADDRESS)) is not None:
if address := callback_matcher.get(ADDRESS):
if service_info := history.get(address):
service_infos = [service_info]
else:
@@ -20,7 +20,7 @@
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.29.11",
"dbus-fast==5.0.3",
"habluetooth==6.3.0"
"dbus-fast==5.0.0",
"habluetooth==6.1.0"
]
}
+2 -2
View File
@@ -26,12 +26,12 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_LOCKED, ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .const import ATTR_OFFSET, ATTR_VALVE
from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE
from .entity import DeconzDevice
from .hub import DeconzHub
+2
View File
@@ -43,6 +43,8 @@ PLATFORMS = [
]
ATTR_DARK = "dark"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCKED = "locked"
ATTR_OFFSET = "offset"
ATTR_ON = "on"
ATTR_VALVE = "valve"
+1 -1
View File
@@ -16,7 +16,7 @@
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.1",
"aiodiscover==3.2.3",
"aiodiscover==3.2.0",
"cached-ipaddress==1.0.1"
]
}
+6 -1
View File
@@ -6,6 +6,7 @@ import logging
import aiodns
from aiodns.error import DNSError
from pycares import AresError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
@@ -77,7 +78,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
) from err
errors = [
result for result in results if isinstance(result, (TimeoutError, DNSError))
result
for result in results
if isinstance(
result, (TimeoutError, DNSError, AresError, asyncio.CancelledError)
)
]
if errors and len(errors) == len(results):
await _close_resolvers()
@@ -14,5 +14,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["guntamatic==1.9.0"]
"requirements": ["guntamatic==1.8.0"]
}
+1 -2
View File
@@ -4,13 +4,12 @@ from dataclasses import dataclass
from typing import cast
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow, llm
from .application_credentials import authorization_server_context
from .const import CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
from .coordinator import ModelContextProtocolCoordinator, TokenManager
from .types import ModelContextProtocolConfigEntry
+8 -2
View File
@@ -13,7 +13,7 @@ from yarl import URL
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
from homeassistant.const import CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -24,7 +24,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from . import async_get_config_entry_implementation
from .application_credentials import authorization_server_context
from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN
from .const import (
CONF_ACCESS_TOKEN,
CONF_AUTHORIZATION_URL,
CONF_SCOPE,
CONF_TOKEN_URL,
DOMAIN,
)
from .coordinator import TokenManager, mcp_client
_LOGGER = logging.getLogger(__name__)
+2
View File
@@ -2,6 +2,8 @@
DOMAIN = "mcp"
# pylint: disable-next=home-assistant-duplicate-const
CONF_ACCESS_TOKEN = "access_token"
CONF_AUTHORIZATION_URL = "authorization_url"
CONF_TOKEN_URL = "token_url"
CONF_SCOPE = "scope"
+5 -1
View File
@@ -41,7 +41,7 @@ from mcp.shared.message import SessionMessage
from homeassistant.components import conversation
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers import llm
@@ -56,6 +56,10 @@ _LOGGER = logging.getLogger(__name__)
STREAMABLE_API = "/api/mcp"
TIMEOUT = 60 # Seconds
# Content types
# pylint: disable-next=home-assistant-duplicate-const
CONTENT_TYPE_JSON = "application/json"
# Legacy SSE endpoint
SSE_API = f"/{DOMAIN}/sse"
MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}"
@@ -15,5 +15,5 @@
"iot_class": "local_push",
"loggers": ["opendisplay"],
"quality_scale": "silver",
"requirements": ["py-opendisplay==7.2.3"]
"requirements": ["py-opendisplay==5.9.0"]
}
@@ -218,7 +218,7 @@ async def _async_upload_image(call: ServiceCall) -> None:
pil_image,
refresh_mode=refresh_mode,
dither_mode=dither_mode,
tone=tone_compression,
tone_compression=tone_compression,
fit=fit_mode,
rotate=rotation,
)
@@ -118,9 +118,6 @@
"services": {
"prune_images": {
"service": "mdi:delete-sweep"
},
"recreate_container": {
"service": "mdi:restart"
}
}
}
@@ -20,9 +20,6 @@ from .coordinator import PortainerConfigEntry
ATTR_DATE_UNTIL = "until"
ATTR_DANGLING = "dangling"
ATTR_TIMEOUT = "timeout"
ATTR_PULL_IMAGE = "pull_image"
ATTR_CONTAINER_DEVICE_ID = "container_device_id"
SERVICE_PRUNE_IMAGES = "prune_images"
SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
@@ -35,17 +32,6 @@ SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
},
)
SERVICE_RECREATE_CONTAINER = "recreate_container"
SERVICE_RECREATE_CONTAINER_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONTAINER_DEVICE_ID): cv.string,
vol.Optional(ATTR_TIMEOUT): vol.All(
cv.time_period, vol.Range(min=timedelta(minutes=1))
),
vol.Optional(ATTR_PULL_IMAGE): cv.boolean,
}
)
async def _extract_config_entry(service_call: ServiceCall) -> PortainerConfigEntry:
"""Extract config entry from the service call."""
@@ -89,45 +75,6 @@ async def _get_endpoint_id(
return endpoint_data.endpoint.id
async def _get_container_and_endpoint_ids(
call: ServiceCall,
) -> tuple[PortainerConfigEntry, int, str]:
"""Get config entry, endpoint ID and container ID from the container device ID."""
device_reg = dr.async_get(call.hass)
device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID])
if device is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
config_entry: PortainerConfigEntry | None = None
for loaded_entry in call.hass.config_entries.async_loaded_entries(DOMAIN):
if loaded_entry.entry_id in device.config_entries:
config_entry = loaded_entry
break
if config_entry is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
coordinator = config_entry.runtime_data
for data in coordinator.data.values():
for container_name, container_data in data.containers.items():
if (
DOMAIN,
f"{config_entry.entry_id}_{data.endpoint.id}_{container_name}",
) in device.identifiers:
return config_entry, data.endpoint.id, container_data.container.id
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
async def prune_images(call: ServiceCall) -> None:
"""Prune unused images in Portainer, with more controls."""
config_entry = await _extract_config_entry(call)
@@ -157,40 +104,6 @@ async def prune_images(call: ServiceCall) -> None:
) from err
async def recreate_container(call: ServiceCall) -> None:
"""Recreate a container in Portainer, with more controls."""
config_entry, endpoint_id, container_id = await _get_container_and_endpoint_ids(
call
)
coordinator = config_entry.runtime_data
timeout: timedelta | None = call.data.get(ATTR_TIMEOUT)
try:
await coordinator.portainer.container_recreate(
endpoint_id=endpoint_id,
container_id=container_id,
**({"timeout": timeout} if timeout is not None else {}),
pull_image=call.data.get(ATTR_PULL_IMAGE, False),
)
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth_no_details",
) from err
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_no_details",
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect_no_details",
) from err
await coordinator.async_request_refresh()
async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
@@ -200,10 +113,3 @@ async def async_setup_services(hass: HomeAssistant) -> None:
prune_images,
SERVICE_PRUNE_IMAGES_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
recreate_container,
SERVICE_RECREATE_CONTAINER_SCHEMA,
)
@@ -16,20 +16,3 @@ prune_images:
required: false
selector:
boolean: {}
recreate_container:
fields:
container_device_id:
required: true
selector:
device:
integration: portainer
model: Container
timeout:
required: false
selector:
duration:
pull_image:
required: false
selector:
boolean:
@@ -235,24 +235,6 @@
}
},
"name": "Prune unused images"
},
"recreate_container": {
"description": "Recreates a container on a Portainer endpoint. This is more disruptive than a restart as the container will be stopped, removed, and then re-created with the same configuration. Use with caution.",
"fields": {
"container_device_id": {
"description": "The container to recreate.",
"name": "Container"
},
"pull_image": {
"description": "Whether to pull the image before recreating the container. This can be used to update the container to the latest version of the image.",
"name": "Pull image"
},
"timeout": {
"description": "The time to wait for the container to stop before killing it. If not provided, a default of 5 minutes will be used.",
"name": "Timeout"
}
},
"name": "Recreate container"
}
},
"system_health": {
+2 -2
View File
@@ -1,7 +1,5 @@
"""Define constants for the SleepIQ component."""
from homeassistant.const import PRESSURE
DATA_SLEEPIQ = "data_sleepiq"
DOMAIN = "sleepiq"
@@ -13,6 +11,8 @@ FIRMNESS = "firmness"
ICON_EMPTY = "mdi:bed-empty"
ICON_OCCUPIED = "mdi:bed"
IS_IN_BED = "is_in_bed"
# pylint: disable-next=home-assistant-duplicate-const
PRESSURE = "pressure"
SLEEP_NUMBER = "sleep_number"
FOOT_WARMING_TIMER = "foot_warming_timer"
FOOT_WARMER = "foot_warmer"
+2 -1
View File
@@ -11,13 +11,14 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PRESSURE, UnitOfTime
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
HEART_RATE,
HRV,
PRESSURE,
RESPIRATORY_RATE,
SLEEP_DURATION,
SLEEP_NUMBER,
+2 -1
View File
@@ -7,7 +7,6 @@ import smarttub
import voluptuous as vol
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -20,6 +19,8 @@ from .entity import SmartTubOnboardSensorBase
# the desired duration, in hours, of the cycle
ATTR_DURATION = "duration"
ATTR_CYCLE_LAST_UPDATED = "cycle_last_updated"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODE = "mode"
# the hour of the day at which to start the cycle (0-23)
ATTR_START_HOUR = "start_hour"
@@ -7,13 +7,14 @@ from surepy.enums import Location
from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
import voluptuous as vol
from homeassistant.const import ATTR_LOCATION, Platform
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_FLAP_ID,
ATTR_LOCATION,
ATTR_LOCK_STATE,
ATTR_PET_NAME,
DOMAIN,
@@ -18,5 +18,7 @@ SURE_BATT_VOLTAGE_DIFF = SURE_BATT_VOLTAGE_FULL - SURE_BATT_VOLTAGE_LOW
SERVICE_SET_LOCK_STATE = "set_lock_state"
SERVICE_SET_PET_LOCATION = "set_pet_location"
ATTR_FLAP_ID = "flap_id"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCATION = "location"
ATTR_LOCK_STATE = "lock_state"
ATTR_PET_NAME = "pet_name"
@@ -8,7 +8,7 @@ from surepy.enums import EntityType, Location, LockState
from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LOCATION, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -16,6 +16,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import (
ATTR_FLAP_ID,
ATTR_LOCATION,
ATTR_LOCK_STATE,
ATTR_PET_NAME,
DOMAIN,
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["uiprotect"],
"quality_scale": "platinum",
"requirements": ["uiprotect==10.5.0"]
"requirements": ["uiprotect==10.4.1"]
}
+4 -4
View File
@@ -1,7 +1,7 @@
# Automatically generated by gen_requirements_all.py, do not edit
aiodhcpwatcher==1.2.1
aiodiscover==3.2.3
aiodiscover==3.2.0
aiodns==4.0.4
aiogithubapi==26.0.0
aiohttp-asyncmdnsresolver==0.1.1
@@ -30,12 +30,12 @@ certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==48.0.0
dbus-fast==5.0.3
dbus-fast==5.0.0
file-read-backwards==2.0.0
fnv-hash-fast==2.0.2
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.3.0
habluetooth==6.1.0
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
@@ -133,7 +133,7 @@ multidict>=6.0.2
Brotli>=1.2.0
# ensure pydantic version does not float since it might have breaking changes
pydantic==2.13.4
pydantic==2.13.2
# Required for Python 3.14.0 compatibility (#119223).
mashumaro>=3.17.0
+6 -6
View File
@@ -233,7 +233,7 @@ aiocomelit==2.0.3
aiodhcpwatcher==1.2.1
# homeassistant.components.dhcp
aiodiscover==3.2.3
aiodiscover==3.2.0
# homeassistant.components.dnsip
aiodns==4.0.4
@@ -794,7 +794,7 @@ datadog==0.52.0
datapoint==0.12.1
# homeassistant.components.bluetooth
dbus-fast==5.0.3
dbus-fast==5.0.0
# homeassistant.components.debugpy
debugpy==1.8.17
@@ -1183,7 +1183,7 @@ growattServer==2.1.0
gspread==5.5.0
# homeassistant.components.guntamatic
guntamatic==1.9.0
guntamatic==1.8.0
# homeassistant.components.profiler
guppy3==3.1.6
@@ -1210,7 +1210,7 @@ ha-xthings-cloud==1.0.5
habiticalib==0.4.7
# homeassistant.components.bluetooth
habluetooth==6.3.0
habluetooth==6.1.0
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -1923,7 +1923,7 @@ py-nightscout==1.2.2
py-nymta==0.4.0
# homeassistant.components.opendisplay
py-opendisplay==7.2.3
py-opendisplay==5.9.0
# homeassistant.components.schluter
py-schluter==0.1.7
@@ -3224,7 +3224,7 @@ uasiren==0.0.1
uhooapi==1.2.8
# homeassistant.components.unifiprotect
uiprotect==10.5.0
uiprotect==10.4.1
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
+1 -1
View File
@@ -18,7 +18,7 @@ license-expression==30.4.3
mock-open==1.4.0
mypy==2.1.0
prek==0.2.28
pydantic==2.13.4
pydantic==2.13.2
pylint==4.0.5
pylint-per-file-ignores==3.2.1
pipdeptree==2.26.1
+1 -1
View File
@@ -117,7 +117,7 @@ multidict>=6.0.2
Brotli>=1.2.0
# ensure pydantic version does not float since it might have breaking changes
pydantic==2.13.4
pydantic==2.13.2
# Required for Python 3.14.0 compatibility (#119223).
mashumaro>=3.17.0
+17 -76
View File
@@ -2,19 +2,13 @@
"""Helper script to split test into n buckets."""
import argparse
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, field
from math import ceil
import os
from pathlib import Path
import subprocess
import sys
from typing import Final
# tests/components has ~1000 sub-directories, which makes it the natural
# place to subdivide to keep each pytest invocation roughly equal in size.
_FAN_OUT_DIRS: Final = frozenset({"components"})
class Bucket:
"""Class to hold bucket."""
@@ -170,86 +164,33 @@ class TestFolder:
return result
def _collect_batch(paths: list[Path]) -> tuple[str, str, int]:
"""Run pytest --collect-only on a batch of paths."""
def collect_tests(path: Path) -> TestFolder:
"""Collect all tests."""
result = subprocess.run(
["pytest", "--collect-only", "-qq", "-p", "no:warnings", *map(str, paths)],
["pytest", "--collect-only", "-qq", "-p", "no:warnings", path],
check=False,
capture_output=True,
text=True,
)
return result.stdout, result.stderr, result.returncode
def _iter_eligible_children(path: Path) -> list[Path]:
"""Return immediate children of ``path`` that pytest should collect.
Filters out hidden/dunder entries, non-``test_*.py`` files (so helper
modules like ``conftest.py`` and ``common.py`` are not passed as
explicit collection targets), and pycache-style directories.
"""
children: list[Path] = []
for entry in sorted(path.iterdir()):
if entry.name.startswith((".", "_")):
continue
if entry.is_dir() or (entry.suffix == ".py" and entry.name.startswith("test_")):
children.append(entry)
return children
def _enumerate_batch_paths(path: Path) -> list[Path]:
"""Return the child paths to run pytest --collect-only over.
Files are returned as-is. Directories are expanded one level deep, with
a second level of expansion for entries named in ``_FAN_OUT_DIRS`` so the
enormous ``tests/components`` tree fans out into per-integration paths.
"""
if path.is_file():
return [path]
paths: list[Path] = []
for entry in _iter_eligible_children(path):
if entry.is_dir() and entry.name in _FAN_OUT_DIRS:
paths.extend(_iter_eligible_children(entry))
else:
paths.append(entry)
return paths
def collect_tests(path: Path) -> TestFolder:
"""Collect all tests."""
batch_paths = _enumerate_batch_paths(path)
if not batch_paths:
print(f"No eligible test paths found under {path}")
if result.returncode != 0:
print("Failed to collect tests:")
print(result.stderr)
print(result.stdout)
sys.exit(1)
workers = min(len(batch_paths), os.cpu_count() or 1) or 1
# Round-robin chunking keeps batches roughly balanced when path
# ordering correlates with test size.
batches = [batch_paths[i::workers] for i in range(workers)]
if workers == 1:
results = [_collect_batch(batches[0])]
else:
with ProcessPoolExecutor(max_workers=workers) as executor:
results = list(executor.map(_collect_batch, batches))
folder = TestFolder(path)
for stdout, stderr, returncode in results:
if returncode != 0:
print("Failed to collect tests:")
print(stderr)
print(stdout)
sys.exit(1)
for line in stdout.splitlines():
if not line.strip():
continue
file_path, _, total_tests = line.partition(": ")
if not file_path or not total_tests:
print(f"Unexpected line: {line}")
sys.exit(1)
file = TestFile(int(total_tests), Path(file_path))
folder.add_test_file(file)
for line in result.stdout.splitlines():
if not line.strip():
continue
file_path, _, total_tests = line.partition(": ")
if not path or not total_tests:
print(f"Unexpected line: {line}")
sys.exit(1)
file = TestFile(int(total_tests), Path(file_path))
folder.add_test_file(file)
return folder
@@ -182,13 +182,8 @@ async def test_diagnostics(
"scanners": [
{
"adapter": "hci0",
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"connectable": True,
"discovered_devices_and_advertisement_data": [],
"last_connect_completed_time": 0.0,
"last_detection": ANY,
"monotonic_time": ANY,
"name": "hci0 (00:00:00:00:00:01)",
@@ -207,11 +202,6 @@ async def test_diagnostics(
},
{
"adapter": "hci1",
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"last_connect_completed_time": 0.0,
"discovered_devices_and_advertisement_data": [
{
"address": "44:44:33:11:23:45",
@@ -407,12 +397,7 @@ async def test_diagnostics_macos(
"scanners": [
{
"adapter": "Core Bluetooth",
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"connectable": True,
"last_connect_completed_time": 0.0,
"discovered_devices_and_advertisement_data": [
{
"address": "44:44:33:11:23:45",
@@ -617,13 +602,8 @@ async def test_diagnostics_remote_adapter(
"scanners": [
{
"adapter": "hci0",
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"connectable": True,
"discovered_devices_and_advertisement_data": [],
"last_connect_completed_time": 0.0,
"last_detection": ANY,
"monotonic_time": ANY,
"name": "hci0 (00:00:00:00:00:01)",
@@ -641,14 +621,9 @@ async def test_diagnostics_remote_adapter(
},
},
{
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"connectable": True,
"current_mode": None,
"requested_mode": None,
"last_connect_completed_time": 0.0,
"discovered_device_timestamps": {"44:44:33:11:23:45": ANY},
"discovered_devices_and_advertisement_data": [
{
+3 -107
View File
@@ -138,10 +138,8 @@ async def test_setup_and_stop_passive(
await hass.async_block_till_done()
assert init_kwargs == {
"bluez": {
**scanner.PASSIVE_SCANNER_ARGS, # pylint: disable=c-extension-no-member
"adapter": "hci0",
},
"adapter": "hci0",
"bluez": scanner.PASSIVE_SCANNER_ARGS, # pylint: disable=c-extension-no-member
"scanning_mode": "passive",
}
@@ -190,7 +188,7 @@ async def test_setup_and_stop_old_bluez(
await hass.async_block_till_done()
assert init_kwargs == {
"bluez": {"adapter": "hci0"},
"adapter": "hci0",
"scanning_mode": "active",
}
@@ -1642,108 +1640,6 @@ async def test_register_callback_by_address(
assert service_info.manufacturer_id == 89
@pytest.mark.usefixtures("enable_bluetooth")
async def test_register_callback_with_scan_interval_registers_active_scan(
hass: HomeAssistant, mock_bleak_scanner_start: MagicMock
) -> None:
"""scan_interval + address forwards to habluetooth's active-scan scheduler."""
mock_bt: list[Any] = []
with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
):
await async_setup_with_default_adapter(hass)
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
manager = bluetooth.api._get_manager(hass)
sched = manager._auto_scheduler
address = "44:44:33:11:23:45"
def _cb(_si: BluetoothServiceInfo, _ch: BluetoothChange) -> None:
return None
cancel = bluetooth.async_register_callback(
hass,
_cb,
{"address": address},
BluetoothScanningMode.ACTIVE,
scan_interval=300.0,
scan_duration=5.0,
)
assert address in sched._requests_by_address
request = next(iter(sched._requests_by_address[address]))
assert request.scan_interval == 300.0
assert request.scan_duration == 5.0
cancel()
assert address not in sched._requests_by_address
@pytest.mark.usefixtures("enable_bluetooth")
async def test_register_callback_passive_mode_skips_active_scan(
hass: HomeAssistant, mock_bleak_scanner_start: MagicMock
) -> None:
"""PASSIVE mode never registers an active-scan request, even with cadence."""
mock_bt: list[Any] = []
with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
):
await async_setup_with_default_adapter(hass)
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
manager = bluetooth.api._get_manager(hass)
sched = manager._auto_scheduler
def _cb(_si: BluetoothServiceInfo, _ch: BluetoothChange) -> None:
return None
cancel = bluetooth.async_register_callback(
hass,
_cb,
{"address": "44:44:33:11:23:45"},
BluetoothScanningMode.PASSIVE,
scan_interval=300.0,
)
assert sched._requests_by_address == {}
cancel()
@pytest.mark.usefixtures("enable_bluetooth")
async def test_register_callback_without_address_skips_active_scan(
hass: HomeAssistant, mock_bleak_scanner_start: MagicMock
) -> None:
"""A scan_interval without an address in the matcher is a no-op for the scheduler."""
mock_bt: list[Any] = []
with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
):
await async_setup_with_default_adapter(hass)
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
manager = bluetooth.api._get_manager(hass)
sched = manager._auto_scheduler
def _cb(_si: BluetoothServiceInfo, _ch: BluetoothChange) -> None:
return None
cancel = bluetooth.async_register_callback(
hass,
_cb,
{SERVICE_UUID: "cba20d00-224d-11e6-9fb8-0002a5d5c51b"},
BluetoothScanningMode.ACTIVE,
scan_interval=300.0,
)
assert sched._requests_by_address == {}
cancel()
@pytest.mark.usefixtures("enable_bluetooth")
async def test_register_callback_by_address_connectable_only(
hass: HomeAssistant, mock_bleak_scanner_start: MagicMock
+4
View File
@@ -1,8 +1,10 @@
"""Test for DNS IP integration Init."""
import asyncio
from unittest.mock import patch
from aiodns.error import DNSError
from pycares import AresError
import pytest
from homeassistant.components.dnsip.const import (
@@ -178,6 +180,8 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None:
[
TimeoutError(),
DNSError(),
AresError(),
asyncio.CancelledError(),
],
)
async def test_setup_dns_error(hass: HomeAssistant, error: Exception) -> None:
@@ -81,16 +81,11 @@ async def test_diagnostics_with_bluetooth(
"connections_free": 0,
"connections_limit": 0,
"scanner": {
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"connectable": True,
"current_mode": None,
"requested_mode": None,
"discovered_device_timestamps": {},
"discovered_devices_and_advertisement_data": [],
"last_connect_completed_time": 0.0,
"last_detection": ANY,
"monotonic_time": ANY,
"name": "test (AA:BB:CC:DD:EE:FC)",
+2 -1
View File
@@ -13,12 +13,13 @@ from homeassistant.components.application_credentials import (
async_import_client_credential,
)
from homeassistant.components.mcp.const import (
CONF_ACCESS_TOKEN,
CONF_AUTHORIZATION_URL,
CONF_SCOPE,
CONF_TOKEN_URL,
DOMAIN,
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
from homeassistant.const import CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
-3
View File
@@ -31,8 +31,6 @@ MOCK_TEST_CONFIG = {
TEST_ENTRY = "portainer_test_entry_123"
TEST_INSTANCE_ID = "299ab403-70a8-4c05-92f7-bf7a994d50df"
TEST_CONTAINER_NAME = "practical_morse"
TEST_CONTAINER_ID = "ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf"
@pytest.fixture
@@ -95,7 +93,6 @@ def mock_portainer_client() -> Generator[AsyncMock]:
client.stop_container = AsyncMock(return_value=None)
client.start_stack = AsyncMock(return_value=None)
client.stop_stack = AsyncMock(return_value=None)
client.container_recreate = AsyncMock(return_value=None)
yield client
+4 -139
View File
@@ -13,13 +13,9 @@ from voluptuous import MultipleInvalid
from homeassistant.components.portainer.const import DOMAIN
from homeassistant.components.portainer.services import (
ATTR_CONTAINER_DEVICE_ID,
ATTR_DANGLING,
ATTR_DATE_UNTIL,
ATTR_PULL_IMAGE,
ATTR_TIMEOUT,
SERVICE_PRUNE_IMAGES,
SERVICE_RECREATE_CONTAINER,
)
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant
@@ -27,17 +23,13 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import DeviceRegistry
from . import setup_integration
from .conftest import TEST_CONTAINER_ID, TEST_CONTAINER_NAME, TEST_ENTRY
from .conftest import TEST_ENTRY
from tests.common import MockConfigEntry
TEST_ENDPOINT_ID = 1
TEST_DEVICE_IDENTIFIER = f"{TEST_ENTRY}_{TEST_ENDPOINT_ID}"
TEST_CONTAINER_DEVICE_IDENTIFIER = (
f"{TEST_ENTRY}_{TEST_ENDPOINT_ID}_{TEST_CONTAINER_NAME}"
)
async def test_services(
hass: HomeAssistant,
@@ -110,99 +102,6 @@ async def test_service_prune_images(
)
@pytest.mark.parametrize(
("call_arguments", "extra_expected_kwargs"),
[
({}, {"pull_image": False}),
(
{ATTR_TIMEOUT: timedelta(minutes=10)},
{"pull_image": False, "timeout": timedelta(minutes=10)},
),
(
{ATTR_TIMEOUT: timedelta(minutes=12), ATTR_PULL_IMAGE: True},
{"pull_image": True, "timeout": timedelta(minutes=12)},
),
],
ids=["no optional", "with duration", "with duration and pull_image"],
)
async def test_service_recreate_container(
hass: HomeAssistant,
device_registry: DeviceRegistry,
mock_portainer_client: AsyncMock,
mock_config_entry: MockConfigEntry,
call_arguments: dict,
extra_expected_kwargs: dict,
) -> None:
"""Test recreate container service with the variants."""
await setup_integration(hass, mock_config_entry)
container = device_registry.async_get_device(
identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)}
)
assert container is not None
await hass.services.async_call(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
{
ATTR_CONTAINER_DEVICE_ID: container.id,
**call_arguments,
},
blocking=True,
)
mock_portainer_client.container_recreate.assert_called_once_with(
endpoint_id=TEST_ENDPOINT_ID,
container_id=TEST_CONTAINER_ID,
**extra_expected_kwargs,
)
@pytest.mark.parametrize(
("exception", "translation_key"),
[
(
PortainerAuthenticationError("auth"),
"invalid_auth_no_details",
),
(
PortainerConnectionError("conn"),
"cannot_connect_no_details",
),
(
PortainerTimeoutError("timeout"),
"timeout_connect_no_details",
),
],
)
async def test_service_recreate_container_portainer_exceptions(
hass: HomeAssistant,
device_registry: DeviceRegistry,
mock_portainer_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: PortainerAuthenticationError
| PortainerConnectionError
| PortainerTimeoutError,
translation_key: str,
) -> None:
"""Test recreate container service handles Portainer exceptions."""
await setup_integration(hass, mock_config_entry)
container = device_registry.async_get_device(
identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)}
)
assert container is not None
mock_portainer_client.container_recreate.side_effect = exception
with pytest.raises(HomeAssistantError) as err:
await hass.services.async_call(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
{ATTR_CONTAINER_DEVICE_ID: container.id},
blocking=True,
)
assert err.value.translation_key == translation_key
mock_portainer_client.container_recreate.assert_called_once()
async def test_service_validation_errors(
hass: HomeAssistant,
device_registry: DeviceRegistry,
@@ -216,11 +115,8 @@ async def test_service_validation_errors(
identifiers={(DOMAIN, TEST_DEVICE_IDENTIFIER)}
)
assert device is not None
container = device_registry.async_get_device(
identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)}
)
assert container is not None
# Test missing device_id
with pytest.raises(MultipleInvalid, match="required key not provided"):
await hass.services.async_call(
DOMAIN,
@@ -230,6 +126,7 @@ async def test_service_validation_errors(
)
mock_portainer_client.images_prune.assert_not_called()
# Test invalid until (too short, needs to be at least 1 minute)
with pytest.raises(MultipleInvalid, match="value must be at least"):
await hass.services.async_call(
DOMAIN,
@@ -239,6 +136,7 @@ async def test_service_validation_errors(
)
mock_portainer_client.images_prune.assert_not_called()
# Test invalid device
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
await hass.services.async_call(
DOMAIN,
@@ -248,39 +146,6 @@ async def test_service_validation_errors(
)
mock_portainer_client.images_prune.assert_not_called()
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
await hass.services.async_call(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
{ATTR_CONTAINER_DEVICE_ID: "invalid_device_id"},
blocking=True,
)
mock_portainer_client.container_recreate.assert_not_called()
other_entry = MockConfigEntry(domain="well_no_portainer_for_sure")
other_entry.add_to_hass(hass)
non_portainer_device = device_registry.async_get_or_create(
config_entry_id=other_entry.entry_id,
identifiers={("well_no_portainer_for_sure", "some_identifier")},
)
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
await hass.services.async_call(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
{ATTR_CONTAINER_DEVICE_ID: non_portainer_device.id},
blocking=True,
)
mock_portainer_client.container_recreate.assert_not_called()
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
await hass.services.async_call(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
{ATTR_CONTAINER_DEVICE_ID: device.id},
blocking=True,
)
mock_portainer_client.container_recreate.assert_not_called()
@pytest.mark.parametrize(
("exception", "message"),
@@ -113,10 +113,6 @@ async def test_rpc_config_entry_diagnostics(
"entry": entry_dict | {"discovery_keys": {}},
"bluetooth": {
"scanner": {
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"connectable": False,
"current_mode": {
"__type": "<enum 'BluetoothScanningMode'>",
@@ -126,7 +122,6 @@ async def test_rpc_config_entry_diagnostics(
"__type": "<enum 'BluetoothScanningMode'>",
"repr": "<BluetoothScanningMode.ACTIVE: 'active'>",
},
"last_connect_completed_time": 0.0,
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY},
"discovered_devices_and_advertisement_data": [
{
+7 -2
View File
@@ -6,10 +6,15 @@ from asyncsleepiq import (
SleepIQTimeoutException,
)
from homeassistant.components.sleepiq.const import DOMAIN, IS_IN_BED, SLEEP_NUMBER
from homeassistant.components.sleepiq.const import (
DOMAIN,
IS_IN_BED,
PRESSURE,
SLEEP_NUMBER,
)
from homeassistant.components.sleepiq.coordinator import UPDATE_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_USERNAME, PRESSURE
from homeassistant.const import CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component