Compare commits

..

5 Commits

Author SHA1 Message Date
Franck Nijhof 60f59862f1 Force a real compile in the template compile benchmark 2026-06-29 21:00:07 +00:00
Franck Nijhof affe38913e Override surepy's stale rich pin so pytest-codspeed resolves 2026-06-29 20:40:20 +00:00
Franck Nijhof be8139f7be Add benchmarks job and benchmark file filter 2026-06-29 20:29:21 +00:00
Franck Nijhof c40e066945 Run benchmarks as a CI job reusing the prepared test environment 2026-06-29 20:27:59 +00:00
Franck Nijhof 30b6730628 Add CodSpeed performance benchmarks for core hot paths 2026-06-29 20:13:27 +00:00
221 changed files with 2985 additions and 4864 deletions
+6
View File
@@ -11,6 +11,12 @@ core: &core
- requirements.txt
- setup.cfg
# Performance benchmark suite (CodSpeed). Only gates the benchmark job; kept out
# of the `any` aggregate below so it does not pull in the full test suite.
benchmarks: &benchmarks
- benchmarks/**
- requirements_test.txt
# Our base platforms, that are used by other integrations
base_platforms: &base_platforms
- homeassistant/components/ai_task/**
@@ -1,125 +0,0 @@
name: Set up Python and restore or build the venv
description: >-
Sets up uv and the managed interpreter, then restores the full venv from the
cache. On a miss it rebuilds the venv from requirements instead of
hard-failing, so jobs survive a transient miss when the entry is evicted under
the repo cache size limit. Only the prepare job sets save to true and writes
the cache, so the validating jobs add no extra cache pressure.
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
python-cache-key:
description: The shared python_cache_key from the info job.
required: true
uv-cache-dir:
description: The uv cache directory (env.UV_CACHE_DIR from the caller).
required: true
apt-cache-version:
description: The apt cache version (env.APT_CACHE_VERSION from the caller).
required: true
save:
description: Whether to save the rebuilt venv and uv cache. Only the prepare job should.
default: "false"
outputs:
python-version:
description: The Python version uv reports as installed.
value: ${{ steps.python.outputs.python-version }}
runs:
using: composite
steps:
- name: Set up uv and managed Python
id: python
uses: ./.github/actions/setup-uv-python
with:
uv-version: ${{ inputs.uv-version }}
python-version: ${{ inputs.python-version }}
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: venv
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
inputs.python-cache-key }}
- name: Generate partial uv restore key
if: steps.cache-venv.outputs.cache-hit != 'true'
id: generate-uv-key
shell: bash
env:
RUNNER_OS: ${{ runner.os }}
RUNNER_ARCH: ${{ runner.arch }}
PYTHON_VERSION: ${{ steps.python.outputs.python-version }}
HASH_FILES: ${{ hashFiles('requirements.txt', 'requirements_all.txt', 'requirements_test.txt', 'homeassistant/package_constraints.txt') }}
run: |
partial_key="${RUNNER_OS}-${RUNNER_ARCH}-${PYTHON_VERSION}-uv-"
echo "partial_key=${partial_key}" >> $GITHUB_OUTPUT
echo "full_key=${partial_key}${HASH_FILES}" >> $GITHUB_OUTPUT
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ inputs.uv-cache-dir }}
key: ${{ steps.generate-uv-key.outputs.full_key }}
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
# Only runs on a cache miss when the venv is rebuilt. Composite steps cannot
# set timeout-minutes, so a stuck apt download is bounded by the job-level
# timeout instead of a per-step cap.
- name: Install additional OS dependencies
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
libavcodec-dev
libavdevice-dev
libavfilter-dev
libavformat-dev
libavutil-dev
libswresample-dev
libswscale-dev
libudev-dev
version: ${{ inputs.apt-cache-version }}
execute_install_scripts: true
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
id: create-venv
shell: bash
env:
PYTHON_VERSION: ${{ steps.python.outputs.python-version }}
run: |
uv venv venv --python "${PYTHON_VERSION}"
. venv/bin/activate
python --version
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: Prune uv cache
if: inputs.save == 'true' && steps.cache-venv.outputs.cache-hit != 'true'
shell: bash
run: |
. venv/bin/activate
uv cache prune --ci
- name: Save uv wheel cache
if: inputs.save == 'true' && steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ inputs.uv-cache-dir }}
key: ${{ steps.generate-uv-key.outputs.full_key }}
- name: Save full Python virtual environment
if: always() && inputs.save == 'true' && steps.create-venv.outcome == 'success'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: venv
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
inputs.python-cache-key }}
@@ -1,46 +0,0 @@
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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.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
# setup-uv only sets UV_PYTHON; it does not fetch the interpreter until uv
# first uses it. Jobs that only restore and activate a cached venv never
# trigger that lazy install, so without this step they hit broken
# interpreter symlinks on a cache hit.
- name: Install Python interpreter
shell: bash
env:
PYTHON_VERSION: ${{ inputs.python-version }}
run: uv python install "${PYTHON_VERSION}"
+1 -1
View File
@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.07.0"
BASE_IMAGE_VERSION: "2026.05.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
+285 -93
View File
@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 4
CACHE_VERSION: 3
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.8"
ADDITIONAL_PYTHON_VERSIONS: "[]"
@@ -89,8 +89,6 @@ 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 }}
@@ -237,11 +235,6 @@ 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,18 +344,82 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }} and build venv
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/restore-or-build-venv
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
save: "true"
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Generate partial uv restore key
if: steps.cache-venv.outputs.cache-hit != 'true'
id: generate-uv-key
env:
RUNNER_OS: ${{ runner.os }}
RUNNER_ARCH: ${{ runner.arch }}
PYTHON_VERSION: ${{ steps.python.outputs.python-version }}
HASH_FILES: ${{ hashFiles('requirements.txt', 'requirements_all.txt', 'requirements_test.txt', 'homeassistant/package_constraints.txt') }}
run: |
partial_key="${RUNNER_OS}-${RUNNER_ARCH}-${PYTHON_VERSION}-uv-"
echo "partial_key=${partial_key}" >> $GITHUB_OUTPUT
echo "full_key=${partial_key}${HASH_FILES}" >> $GITHUB_OUTPUT
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ env.UV_CACHE_DIR }}
key: ${{ steps.generate-uv-key.outputs.full_key }}
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
- name: Install additional OS dependencies
if: steps.cache-venv.outputs.cache-hit != 'true'
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
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: 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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.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
run: |
python -m venv venv
. venv/bin/activate
python --version
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
@@ -377,6 +434,26 @@ jobs:
- name: Check dirty
run: |
./script/check_dirty
- name: Prune uv cache
if: steps.cache-venv.outputs.cache-hit != 'true'
id: prune-uv-cache
run: |
. venv/bin/activate
uv cache prune --ci
- name: Save uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ env.UV_CACHE_DIR }}
key: ${{ steps.generate-uv-key.outputs.full_key }}
- name: Save base Python virtual environment
if: always() && steps.create-venv.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
hassfest:
name: Check hassfest
@@ -401,15 +478,21 @@ jobs:
with:
packages: libturbojpeg
version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python and restore venv
- name: Set up Python
id: python
uses: ./.github/actions/restore-or-build-venv
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Run hassfest
run: |
. venv/bin/activate
@@ -432,15 +515,21 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python and restore venv
- name: Set up Python
id: python
uses: ./.github/actions/restore-or-build-venv
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Run gen_requirements_all.py
run: |
. venv/bin/activate
@@ -464,13 +553,13 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-version-file: ".python-version"
check-latest: true
- name: Run gen_copilot_instructions.py
run: |
uv run --no-project python -m script.gen_copilot_instructions validate
python -m script.gen_copilot_instructions validate
dependency-review:
name: Dependency review
@@ -517,15 +606,21 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }} and restore venv
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/restore-or-build-venv
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Extract license data
env:
PYTHON_VERSION: ${{ matrix.python-version }}
@@ -562,15 +657,21 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python and restore venv
- name: Set up Python
id: python
uses: ./.github/actions/restore-or-build-venv
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Register pylint problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/pylint.json"
@@ -609,15 +710,21 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python and restore venv
- name: Set up Python
id: python
uses: ./.github/actions/restore-or-build-venv
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Register pylint problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/pylint.json"
@@ -654,23 +761,29 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
check-latest: true
- name: Generate partial mypy restore key
id: generate-mypy-key
run: |
mypy_version=$(cat requirements_test.txt | grep 'mypy.*=' | cut -d '=' -f 3)
echo "version=${mypy_version}" >> $GITHUB_OUTPUT
echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Set up Python and restore venv
id: python
uses: ./.github/actions/restore-or-build-venv
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .mypy_cache
key: >-
@@ -699,6 +812,55 @@ jobs:
python --version
mypy --num-workers=4 $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
benchmarks:
name: Run benchmarks
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write # OIDC token CodSpeed mints (no CODSPEED_TOKEN secret)
needs:
- info
- base
# Run only when core code or the benchmark suite itself changed. Skipped on
# forks, where the OIDC token CodSpeed needs is unavailable. Pushes to dev
# that touch core refresh the CodSpeed baseline.
if: >-
needs.info.outputs.lint_only != 'true'
&& (github.event_name != 'pull_request'
|| !github.event.pull_request.head.repo.fork)
&& (contains(fromJSON(needs.info.outputs.core), 'core')
|| contains(fromJSON(needs.info.outputs.core), 'benchmarks'))
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python ${{ fromJson(needs.info.outputs.python_versions)[0] }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ fromJson(needs.info.outputs.python_versions)[0] }}
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Run benchmarks
uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1
with:
# v4 makes `mode` required; "simulation" is the instrumented
# measurement that gives stable, machine-independent results.
mode: simulation
# No token: auth uses the OIDC id-token minted by the job permissions.
run: |
. venv/bin/activate
pytest benchmarks --codspeed --no-cov -o addopts=""
prepare-pytest-full:
name: Split tests for full run
runs-on: ubuntu-24.04
@@ -725,15 +887,21 @@ jobs:
libturbojpeg
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python and restore venv
- name: Set up Python
id: python
uses: ./.github/actions/restore-or-build-venv
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Run split_tests.py
env:
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
@@ -784,15 +952,21 @@ jobs:
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }} and restore venv
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/restore-or-build-venv
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Register Python problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/python.json"
@@ -920,15 +1094,21 @@ jobs:
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }} and restore venv
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/restore-or-build-venv
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Register Python problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/python.json"
@@ -1070,15 +1250,21 @@ jobs:
with:
packages: postgresql-server-dev-14
version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python ${{ matrix.python-version }} and restore venv
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/restore-or-build-venv
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Register Python problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/python.json"
@@ -1232,15 +1418,21 @@ jobs:
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }} and restore venv
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/restore-or-build-venv
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Register Python problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/python.json"
+2 -2
View File
@@ -137,7 +137,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716 # 2026.07.0
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -195,7 +195,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716 # 2026.07.0
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
+1
View File
@@ -0,0 +1 @@
"""CodSpeed performance benchmarks for Home Assistant core hot paths."""
+45
View File
@@ -0,0 +1,45 @@
"""Shared fixtures for the CodSpeed benchmark suite.
These benchmarks live outside ``tests`` on purpose: ``testpaths`` only points at
``tests``, so the regular suite never collects them. CodSpeed runs them with
``pytest benchmarks --codspeed`` and tracks the results per pull request.
"""
from collections.abc import AsyncGenerator, Callable
import pytest
from homeassistant.core import HomeAssistant
from tests.common import async_test_home_assistant
@pytest.fixture
async def hass() -> AsyncGenerator[HomeAssistant]:
"""Return a running Home Assistant instance for benchmarking.
Most hot paths under test (``async_fire``, ``async_set``, ``async_render``)
are ``@callback`` methods, so the benchmark fixture can drive them
synchronously from within the running loop.
"""
async with async_test_home_assistant() as hass:
yield hass
@pytest.fixture
def populate_states(hass: HomeAssistant) -> Callable[[int], None]:
"""Return a helper that fills the state machine with ``count`` sensors.
Used by the scaling benchmarks to measure a path at several sizes, so an
algorithmic regression shows up as the curve bending instead of hiding
behind a single constant-factor number.
"""
def _populate(count: int) -> None:
for index in range(count):
hass.states.async_set(
f"sensor.bench_{index}",
str(index),
{"friendly_name": f"Bench {index}", "unit_of_measurement": "W"},
)
return _populate
+148
View File
@@ -0,0 +1,148 @@
"""CodSpeed benchmarks for the event bus and event helpers.
The event bus carries every state change, and ``async_track_state_change_event``
is the routing layer almost every automation, template and trigger sits on. A
regression in either is felt across the whole system.
Run locally with: ``pytest benchmarks --codspeed``.
"""
from collections.abc import Callable
import pytest
from pytest_codspeed import BenchmarkFixture
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
@callback
def _noop(event: Event) -> None:
"""Do nothing, cheaply."""
def test_event_fire_no_listeners(
benchmark: BenchmarkFixture, hass: HomeAssistant
) -> None:
"""Fire an event nobody listens to (the bare dispatch cost)."""
benchmark(lambda: hass.bus.async_fire("benchmark_event", {"value": 1}))
@pytest.mark.parametrize("listeners", [1, 10])
def test_event_fire_callbacks(
benchmark: BenchmarkFixture, hass: HomeAssistant, listeners: int
) -> None:
"""Fire an event with N callback listeners that run inline."""
fired = 0
@callback
def listener(event: Event) -> None:
nonlocal fired
fired += 1
for _ in range(listeners):
hass.bus.async_listen("benchmark_event", listener)
benchmark(lambda: hass.bus.async_fire("benchmark_event", {"value": 1}))
assert fired
def test_event_fire_filtered_reject(
benchmark: BenchmarkFixture, hass: HomeAssistant
) -> None:
"""Fire an event whose listener is gated out by an event_filter.
The filter runs but the listener does not, so this isolates the filter
short-circuit cost from the listener body.
"""
@callback
def event_filter(event_data: dict) -> bool:
return False
hass.bus.async_listen("benchmark_event", _noop, event_filter=event_filter)
benchmark(lambda: hass.bus.async_fire("benchmark_event", {"value": 1}))
def test_state_change_tracked(benchmark: BenchmarkFixture, hass: HomeAssistant) -> None:
"""Fire a state change routed to a tracked entity's listener.
This is the real automation hot path: ``async_set`` fires
EVENT_STATE_CHANGED, the dispatcher does a dict lookup on the entity_id and
runs the inline callback.
"""
fired = 0
@callback
def listener(event: Event) -> None:
nonlocal fired
fired += 1
async_track_state_change_event(hass, "sensor.tracked", listener)
counter = 0
def _set() -> None:
nonlocal counter
counter += 1
hass.states.async_set("sensor.tracked", str(counter))
benchmark(_set)
assert fired
def test_state_change_untracked(
benchmark: BenchmarkFixture, hass: HomeAssistant
) -> None:
"""Fire a state change for an entity nobody tracks (the dict-miss path).
Tracking is installed for a different entity, so the dispatcher's lookup
misses and returns fast. This is the common case on a busy bus.
"""
async_track_state_change_event(hass, "sensor.tracked", _noop)
counter = 0
def _set() -> None:
nonlocal counter
counter += 1
hass.states.async_set("sensor.untracked", str(counter))
benchmark(_set)
@pytest.mark.parametrize("receivers", [0, 1, 10])
def test_dispatcher_send(
benchmark: BenchmarkFixture, hass: HomeAssistant, receivers: int
) -> None:
"""Send a dispatcher signal to N connected receivers."""
fired = 0
@callback
def receiver(*args: object) -> None:
nonlocal fired
fired += 1
for _ in range(receivers):
async_dispatcher_connect(hass, "benchmark_signal", receiver)
benchmark(lambda: async_dispatcher_send(hass, "benchmark_signal", 1))
def test_call_later_schedule(benchmark: BenchmarkFixture, hass: HomeAssistant) -> None:
"""Schedule a delayed callback and cancel it (the timer-tracking cost).
Cancelling inside the measured call keeps timers from piling up on the loop
across iterations.
"""
def _schedule() -> None:
cancel: Callable[[], None] = async_call_later(hass, 60, _noop)
cancel()
benchmark(_schedule)
+138
View File
@@ -0,0 +1,138 @@
"""CodSpeed benchmarks for the state machine and entity write path.
Every state update in Home Assistant flows through ``StateMachine.async_set``,
and every entity that pushes an update lands in ``Entity._async_write_ha_state``.
These are among the busiest call sites in the whole process.
Run locally with: ``pytest benchmarks --codspeed``.
"""
from collections.abc import Callable
from typing import Any
import pytest
from pytest_codspeed import BenchmarkFixture
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.entity import Entity
_ATTRS = {
"friendly_name": "Benchmark light",
"brightness": 255,
"color_temp_kelvin": 4000,
"supported_color_modes": ["color_temp", "rgb"],
}
def test_state_set_create(benchmark: BenchmarkFixture, hass: HomeAssistant) -> None:
"""Write a brand new entity into the state machine (the create branch).
``pedantic`` with a teardown that removes the entity keeps every measured
call on the create path; without it only the first call creates and the rest
measure the update path.
"""
benchmark.pedantic(
lambda: hass.states.async_set("light.benchmark", "on", _ATTRS),
teardown=lambda: hass.states.async_remove("light.benchmark"),
rounds=1000,
)
def test_state_set_update(benchmark: BenchmarkFixture, hass: HomeAssistant) -> None:
"""Write a changed state for an existing entity (the update branch)."""
counter = 0
def _set() -> None:
nonlocal counter
counter += 1
hass.states.async_set("light.benchmark", str(counter), _ATTRS)
benchmark(_set)
assert hass.states.get("light.benchmark") is not None
def test_state_set_report(benchmark: BenchmarkFixture, hass: HomeAssistant) -> None:
"""Re-set an unchanged state (the EVENT_STATE_REPORTED fast path).
Polling integrations hammer this branch: same value, same attributes, over
and over. It fires a lightweight reported event instead of a state change.
"""
hass.states.async_set("sensor.benchmark", "21.5", _ATTRS)
benchmark(lambda: hass.states.async_set("sensor.benchmark", "21.5", _ATTRS))
def test_state_get(benchmark: BenchmarkFixture, hass: HomeAssistant) -> None:
"""Look a single state up by entity_id."""
hass.states.async_set("sensor.benchmark", "21.5", _ATTRS)
state: State | None = benchmark(lambda: hass.states.get("sensor.benchmark"))
assert state is not None
@pytest.mark.parametrize("count", [10, 100, 1000])
def test_state_all(
benchmark: BenchmarkFixture,
populate_states: Callable[[int], None],
hass: HomeAssistant,
count: int,
) -> None:
"""Read the full state list out of a populated machine, at several sizes."""
populate_states(count)
states: list[State] = benchmark(hass.states.async_all)
assert len(states) == count
@pytest.mark.parametrize("count", [10, 100, 1000])
def test_state_entity_ids(
benchmark: BenchmarkFixture,
populate_states: Callable[[int], None],
hass: HomeAssistant,
count: int,
) -> None:
"""List entity ids out of a populated machine, at several sizes."""
populate_states(count)
entity_ids = benchmark(hass.states.async_entity_ids)
assert len(entity_ids) == count
class _BenchmarkEntity(Entity):
"""A minimal entity carrying capability and extra state attributes."""
_attr_should_poll = False
_attr_name = "Benchmark"
_attr_supported_features = 3
def __init__(self, state: str) -> None:
"""Initialize the benchmark entity."""
self._attr_state = state
self._attr_extra_state_attributes: dict[str, Any] = {
"brightness": 255,
"color_temp_kelvin": 4000,
}
@property
def capability_attributes(self) -> dict[str, Any]:
"""Return capability attributes, assembled on every write."""
return {"supported_color_modes": ["color_temp", "rgb"]}
def test_entity_write(benchmark: BenchmarkFixture, hass: HomeAssistant) -> None:
"""Write an entity's state through the entity layer.
This measures ``__async_calculate_state`` (assembling state and attributes
from the entity's properties) plus the ``async_set`` it lands in.
"""
entity = _BenchmarkEntity("on")
entity.hass = hass
entity.entity_id = "light.benchmark"
benchmark(entity._async_write_ha_state) # noqa: SLF001
assert hass.states.get("light.benchmark") is not None
+112
View File
@@ -0,0 +1,112 @@
"""CodSpeed benchmarks for the template engine.
Templates render on dashboards, in automations and in many entity attributes.
Both the compile step and the warm render matter, and templates that walk the
state machine scale with the number of entities.
Run locally with: ``pytest benchmarks --codspeed``.
"""
from collections.abc import Callable
import pytest
from pytest_codspeed import BenchmarkFixture
from homeassistant.core import HomeAssistant
from homeassistant.helpers.template import Template
_STATES_TEMPLATE = (
"{{ (states('sensor.power') | float / 1000) | round(2) }} kW "
"{{ is_state('binary_sensor.motion', 'on') }} "
"{{ state_attr('sensor.power', 'unit_of_measurement') }}"
)
def test_template_compile(benchmark: BenchmarkFixture, hass: HomeAssistant) -> None:
"""Compile a template from source (parse plus codegen).
A unique source on every call (a varying Jinja comment) keeps the
environment's template cache missing, so each run actually compiles instead
of returning cached bytecode. The comment is stripped during compilation, so
the cost matches the real template.
"""
counter = 0
def _compile() -> Template:
nonlocal counter
counter += 1
template = Template(f"{{# {counter} #}}{_STATES_TEMPLATE}", hass)
template.ensure_valid()
return template
template = benchmark(_compile)
assert template.is_static is False
def test_template_render_simple(
benchmark: BenchmarkFixture, hass: HomeAssistant
) -> None:
"""Render a pure-math template (warm), the engine's baseline overhead."""
template = Template("{{ 1 + 1 }}", hass)
template.ensure_valid()
result = benchmark(template.async_render)
assert result == 2
def test_template_render_states(
benchmark: BenchmarkFixture, hass: HomeAssistant
) -> None:
"""Render a template that reads states, attributes and a filter (warm)."""
hass.states.async_set("sensor.power", "1200", {"unit_of_measurement": "W"})
hass.states.async_set("binary_sensor.motion", "on")
template = Template(_STATES_TEMPLATE, hass)
template.ensure_valid()
result = benchmark(template.async_render)
assert result.startswith("1.2 kW")
def test_template_render_to_info(
benchmark: BenchmarkFixture, hass: HomeAssistant
) -> None:
"""Render and collect the entity dependency filter (the tracking path).
``async_render_to_info`` is what template triggers and template entities use
to learn which entities to subscribe to.
"""
hass.states.async_set("sensor.power", "1200", {"unit_of_measurement": "W"})
hass.states.async_set("binary_sensor.motion", "on")
template = Template(_STATES_TEMPLATE, hass)
template.ensure_valid()
info = benchmark(template.async_render_to_info)
assert info.entities or info.all_states
@pytest.mark.parametrize("count", [10, 100, 1000])
def test_template_iterate_states(
benchmark: BenchmarkFixture,
populate_states: Callable[[int], None],
hass: HomeAssistant,
count: int,
) -> None:
"""Render a template that walks every sensor state, at several sizes.
This is where an O(n) template touches an O(n) state machine; the cost
should grow linearly and a worse-than-linear regression should stand out.
"""
populate_states(count)
template = Template(
"{{ states.sensor | selectattr('state', 'eq', '1') | list | count }}",
hass,
)
template.ensure_valid()
result = benchmark(template.async_render)
assert result == 1
+1 -1
View File
@@ -108,7 +108,7 @@ class AdGuardHomeSensor(AdGuardHomeEntity, SensorEntity):
"""Initialize AdGuard Home sensor."""
super().__init__(data, entry)
self.entity_description = description
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain
[
DOMAIN,
self.adguard.host,
+1 -1
View File
@@ -103,7 +103,7 @@ class AdGuardHomeSwitch(AdGuardHomeEntity, SwitchEntity):
"""Initialize AdGuard Home switch."""
super().__init__(data, entry)
self.entity_description = description
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain
[
DOMAIN,
self.adguard.host,
+1 -1
View File
@@ -46,7 +46,7 @@ class AdGuardHomeUpdate(AdGuardHomeEntity, UpdateEntity):
"""Initialize AdGuard Home update."""
super().__init__(data, entry)
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain
[DOMAIN, self.adguard.host, str(self.adguard.port), "update"]
)
@@ -5,7 +5,7 @@ import logging
from typing import Final, final, override
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfDensity
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -152,4 +152,4 @@ class AirQualityEntity(Entity):
@override
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of this entity."""
return UnitOfDensity.MICROGRAMS_PER_CUBIC_METER
return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
@@ -5,7 +5,13 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import STATE_OFF, STATE_ON, UnitOfDensity, UnitOfRatio
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
@@ -54,12 +60,12 @@ CONDITIONS: dict[str, type[Condition]] = {
# Numerical sensor conditions with unit conversion
"is_co_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"is_ozone_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"is_voc_value": make_entity_numerical_condition_with_unit(
@@ -68,7 +74,7 @@ CONDITIONS: dict[str, type[Condition]] = {
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"is_voc_ratio_value": make_entity_numerical_condition_with_unit(
@@ -77,48 +83,48 @@ CONDITIONS: dict[str, type[Condition]] = {
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
UnitOfRatio.PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"is_no_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"is_no2_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"is_so2_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
# Numerical sensor conditions without unit conversion (single-unit device classes)
"is_co2_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=UnitOfRatio.PARTS_PER_MILLION,
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"is_pm1_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm25_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm4_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm10_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_n2o_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
}
+33 -27
View File
@@ -5,7 +5,13 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import STATE_OFF, STATE_ON, UnitOfDensity, UnitOfRatio
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
@@ -59,25 +65,25 @@ TRIGGERS: dict[str, type[Trigger]] = {
# Numerical sensor triggers with unit conversion
"co_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"co_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
)
),
"ozone_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"ozone_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
)
),
@@ -87,7 +93,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"voc_crossed_threshold": (
@@ -97,7 +103,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
)
),
@@ -107,7 +113,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
UnitOfRatio.PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"voc_ratio_crossed_threshold": (
@@ -117,13 +123,13 @@ TRIGGERS: dict[str, type[Trigger]] = {
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
UnitOfRatio.PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
)
),
"no_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no_crossed_threshold": (
@@ -133,13 +139,13 @@ TRIGGERS: dict[str, type[Trigger]] = {
device_class=SensorDeviceClass.NITROGEN_MONOXIDE
)
},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
)
),
"no2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"no2_crossed_threshold": (
@@ -149,70 +155,70 @@ TRIGGERS: dict[str, type[Trigger]] = {
device_class=SensorDeviceClass.NITROGEN_DIOXIDE
)
},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
)
),
"so2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
"so2_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
)
),
# Numerical sensor triggers without unit conversion (single-unit device classes)
"co2_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=UnitOfRatio.PARTS_PER_MILLION,
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"co2_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=UnitOfRatio.PARTS_PER_MILLION,
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"pm1_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm1_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm25_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm25_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm4_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm4_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm10_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm10_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"n2o_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"n2o_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
}
@@ -40,7 +40,7 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
def __init__(self, coordinator: AirGradientCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.serial_number}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{coordinator.serial_number}-update"
@cached_property
@override
+1 -1
View File
@@ -56,7 +56,7 @@ class AirOSUpdateEntity(AirOSEntity, UpdateEntity):
self.status = status
self.firmware = firmware
self._attr_unique_id = f"{status.data.derived.mac}_firmware_update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{status.data.derived.mac}_firmware_update"
@property
@override
@@ -43,7 +43,7 @@ class IPWebcamCamera(MjpegCamera):
username=coordinator.config_entry.data.get(CONF_USERNAME),
password=coordinator.config_entry.data.get(CONF_PASSWORD, ""),
)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-camera"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
name=coordinator.config_entry.data[CONF_HOST],
@@ -28,6 +28,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, llm
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
@@ -65,7 +66,7 @@ from .const import (
TOOL_SEARCH_UNSUPPORTED_MODELS,
PromptCaching,
)
from .coordinator import async_create_client, model_alias
from .coordinator import model_alias
if TYPE_CHECKING:
from . import AnthropicConfigEntry
@@ -94,7 +95,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
client = await async_create_client(hass, data[CONF_API_KEY])
client = anthropic.AsyncAnthropic(
api_key=data[CONF_API_KEY], http_client=get_async_client(hass)
)
await client.models.list(timeout=10.0)
@@ -546,8 +549,9 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
location_data: dict[str, str] = {}
zone_home = self.hass.states.get(ENTITY_ID_HOME)
if zone_home is not None:
client = await async_create_client(
self.hass, self._get_entry().data[CONF_API_KEY]
client = anthropic.AsyncAnthropic(
api_key=self._get_entry().data[CONF_API_KEY],
http_client=get_async_client(self.hass),
)
location_schema = vol.Schema(
{
@@ -1,7 +1,6 @@
"""Coordinator for the Anthropic integration."""
import datetime
from functools import partial
from typing import override
import anthropic
@@ -21,19 +20,6 @@ UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
async def async_create_client(
hass: HomeAssistant, api_key: str
) -> anthropic.AsyncAnthropic:
"""Create an Anthropic client."""
return await hass.async_add_executor_job(
partial(
anthropic.AsyncAnthropic,
api_key=api_key,
http_client=get_async_client(hass),
)
)
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
@@ -47,8 +33,7 @@ def model_alias(model_id: str) -> str:
class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]):
"""Coordinator using different intervals after success and failure."""
config_entry: AnthropicConfigEntry
_client: anthropic.AsyncAnthropic
client: anthropic.AsyncAnthropic
def __init__(self, hass: HomeAssistant, config_entry: AnthropicConfigEntry) -> None:
"""Initialize the coordinator."""
@@ -61,17 +46,8 @@ class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]
update_method=self.async_update_data,
always_update=False,
)
@property
def client(self) -> anthropic.AsyncAnthropic:
"""Return the Anthropic client."""
return self._client
@override
async def _async_setup(self) -> None:
"""Set up the coordinator."""
self._client = await async_create_client(
self.hass, self.config_entry.data[CONF_API_KEY]
self.client = anthropic.AsyncAnthropic(
api_key=config_entry.data[CONF_API_KEY], http_client=get_async_client(hass)
)
@callback
+15 -12
View File
@@ -27,6 +27,7 @@ from homeassistant.const import ( # noqa: F401
CONF_PATH,
CONF_TRIGGERS,
CONF_VARIABLES,
EVENT_HOMEASSISTANT_STARTED,
SERVICE_RELOAD,
SERVICE_TOGGLE,
SERVICE_TURN_OFF,
@@ -37,7 +38,7 @@ from homeassistant.core import (
CALLBACK_TYPE,
Context,
CoreState,
HassJob,
Event,
HomeAssistant,
ServiceCall,
callback,
@@ -829,13 +830,13 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
if self._condition is not None:
self._condition.async_unload()
async def _async_enable_automation(self) -> None:
"""Arm the automation's triggers on startup."""
async def _async_enable_automation(self, event: Event) -> None:
"""Start automation on startup."""
# Don't do anything if no longer enabled or already attached
if not self._is_enabled or self._async_detach_triggers is not None:
return
self._async_detach_triggers = await self._async_attach_triggers()
self._async_detach_triggers = await self._async_attach_triggers(True)
self.async_write_ha_state()
async def _async_enable(self) -> None:
@@ -850,14 +851,13 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
self._is_enabled = True
# HomeAssistant is starting up
if self.hass.state is not CoreState.not_running:
self._async_detach_triggers = await self._async_attach_triggers()
self._async_detach_triggers = await self._async_attach_triggers(False)
return
# Arm the triggers in a startup job, which runs after all listeners to
# EVENT_HOMEASSISTANT_START have run but before EVENT_HOMEASSISTANT_STARTED
# has fired. This ensures automations do not fire during startup, but
# triggers listening for the started event are armed in time to catch it.
self.hass.async_add_startup_job(HassJob(self._async_enable_automation))
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED,
self._async_enable_automation,
)
async def _async_disable(self, stop_actions: bool = DEFAULT_STOP_ACTIONS) -> None:
"""Disable the automation entity.
@@ -942,7 +942,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
script_execution_set("not_triggered")
async def _async_attach_triggers(self) -> Callable[[], None] | None:
async def _async_attach_triggers(
self, home_assistant_start: bool
) -> Callable[[], None] | None:
"""Set up the triggers."""
this = None
if state := self.hass.states.get(self.entity_id):
@@ -966,7 +968,8 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
DOMAIN,
str(self.name),
self._log_callback,
variables=variables,
home_assistant_start,
variables,
did_not_trigger=self._handle_not_triggered,
)
+1 -1
View File
@@ -34,7 +34,7 @@ class AutomaticBackupEvent(BackupManagerBaseEntity, EventEntity):
def __init__(self, coordinator: BackupDataUpdateCoordinator) -> None:
"""Initialize the automatic backup event."""
super().__init__(coordinator)
self._attr_unique_id = "automatic_backup_event" # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = "automatic_backup_event" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
self._attr_translation_key = "automatic_backup_event"
@callback
+1 -1
View File
@@ -56,7 +56,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
super().__init__(coordinator)
Camera.__init__(self)
self._camera = camera
self._attr_unique_id = f"{camera.serial}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{camera.serial}-camera"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, camera.serial)},
serial_number=camera.serial,
+1 -1
View File
@@ -187,7 +187,7 @@ async def async_process_advertisements(
)
stack.callback(unload)
if mode is BluetoothScanningMode.ACTIVE:
if mode == BluetoothScanningMode.ACTIVE:
task = hass.async_create_task(manager.async_request_active_scan(timeout))
stack.callback(task.cancel)
@@ -1,7 +1,7 @@
{
"domain": "bluetooth_adapters",
"name": "Bluetooth Adapters",
"after_dependencies": ["esphome", "shelly", "ruuvi_gateway", "smlight"],
"after_dependencies": ["esphome", "shelly", "ruuvi_gateway"],
"codeowners": ["@bdraco"],
"dependencies": ["bluetooth"],
"documentation": "https://www.home-assistant.io/integrations/bluetooth_adapters",
+1 -1
View File
@@ -36,7 +36,7 @@ class BroadlinkTime(BroadlinkEntity, TimeEntity):
"""Initialize the sensor."""
super().__init__(device)
self._attr_unique_id = f"{device.unique_id}-device_time" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{device.unique_id}-device_time"
@override
def _update_state(self, data: dict[str, Any]) -> None:
+2 -2
View File
@@ -93,9 +93,9 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
# Backward compatible unique ID: circuit 1 keeps old format
if circuit == 1:
self._attr_unique_id = f"{mac}-climate" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{mac}-climate"
else:
self._attr_unique_id = f"{mac}-climate-{circuit}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{mac}-climate-{circuit}"
# Set temperature range from per-circuit static data
if (static := data.static.get(circuit)) is not None:
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ccm15",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["py_ccm15==0.6.0"]
}
@@ -1,90 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not register any service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not register any service actions.
docs-conditions:
status: exempt
comment: This integration does not have any conditions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-triggers:
status: exempt
comment: This integration does not have any triggers.
entity-event-setup:
status: exempt
comment: Entities poll through the coordinator and do not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not register any service actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration has no options flow.
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: The device connection is unauthenticated; revisit when optional password (pwd=) support lands.
test-coverage: todo
# Gold
devices: done
diagnostics: done
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category:
status: exempt
comment: The single climate entity does not need a non-default category.
entity-device-class:
status: exempt
comment: The climate entity has no applicable device class.
entity-disabled-by-default:
status: exempt
comment: Only primary climate entities are provided.
entity-translations:
status: exempt
comment: The climate entity uses the device name; no entity names to translate.
exception-translations: todo
icon-translations:
status: exempt
comment: The integration uses default entity icons.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: This integration does not raise repairable issues.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -12,10 +12,6 @@
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of your CCM15 controller.",
"port": "The TCP port of the CCM15 controller's HTTP interface."
}
}
}
@@ -1,26 +0,0 @@
"""Diagnostics platform for CentriConnect/MyPropane API integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from . import CentriConnectConfigEntry
TO_REDACT = {"Latitude", "Longitude", "Altitude"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: CentriConnectConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for the provided config entry."""
coord = entry.runtime_data
return {
"device_info": {
"device_id": coord.device_info.device_id,
"device_name": coord.device_info.device_name,
"hardware_version": coord.device_info.hardware_version,
"lte_version": coord.device_info.lte_version,
},
"tank_data": async_redact_data(coord.data.raw_data, TO_REDACT),
}
@@ -47,7 +47,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery:
status: exempt
comment: This is a cloud polling integration with no local discovery mechanism.
+4 -21
View File
@@ -8,7 +8,6 @@ from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
NotTriggeredReasonReporter,
Trigger,
)
@@ -31,11 +30,7 @@ class CounterBaseIntegerTrigger(EntityTriggerBase):
_schema = ENTITY_STATE_TRIGGER_SCHEMA
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is valid."""
return _is_integer_state(state)
@@ -68,11 +63,7 @@ class CounterMaxReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its maximum value."""
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
if (max_value := state.attributes.get(CONF_MAXIMUM)) is None:
return False
@@ -83,11 +74,7 @@ class CounterMinReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its minimum value."""
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
if (min_value := state.attributes.get(CONF_MINIMUM)) is None:
return False
@@ -98,11 +85,7 @@ class CounterResetTrigger(CounterValueBaseTrigger):
"""Trigger for reset of counter entities."""
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
if (init_state := state.attributes.get(CONF_INITIAL)) is None:
return False
+2 -10
View File
@@ -5,11 +5,7 @@ from typing import override
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import (
EntityTriggerBase,
NotTriggeredReasonReporter,
Trigger,
)
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
from .models import CoverDomainSpec
@@ -28,11 +24,7 @@ class CoverTriggerBase(EntityTriggerBase):
return state.state
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
def is_valid_state(self, state: State) -> bool:
"""Check if the state matches the target cover state."""
domain_spec = self._domain_specs[state.domain]
return self._get_value(state) == domain_spec.target_value
+1 -1
View File
@@ -149,7 +149,7 @@ class DemoWeather(WeatherEntity):
) -> None:
"""Initialize the Demo weather."""
self._attr_name = f"Demo Weather {name}"
self._attr_unique_id = f"demo-weather-{name.lower()}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"demo-weather-{name.lower()}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
self._condition = condition
self._native_temperature = temperature
self._native_temperature_unit = temperature_unit
+2 -10
View File
@@ -10,11 +10,7 @@ from homeassistant.components.event import (
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
NotTriggeredReasonReporter,
StatelessEntityTriggerBase,
Trigger,
)
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
class DoorbellRangTrigger(StatelessEntityTriggerBase):
@@ -23,11 +19,7 @@ class DoorbellRangTrigger(StatelessEntityTriggerBase):
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
def is_valid_state(self, state: State) -> bool:
"""Check if the event type is ring."""
return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
+3 -1
View File
@@ -83,7 +83,9 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
translation_key="time_state_end",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda node: (
dt_util.utc_from_timestamp(node.ventilation.time_state_end)
dt_util.utc_from_timestamp(node.ventilation.time_state_end).replace(
second=0, microsecond=0
)
if node.ventilation and node.ventilation.time_state_end != 0
else None
),
+1 -1
View File
@@ -31,7 +31,7 @@ class EcobeeNotifyEntity(EcobeeBaseEntity, NotifyEntity):
"""Initialize the thermostat."""
super().__init__(data, thermostat_index)
self._attr_unique_id = (
f"{self.thermostat['identifier']}_notify_{thermostat_index}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
f"{self.thermostat['identifier']}_notify_{thermostat_index}"
)
@override
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aioecowitt==2026.6.0"]
"requirements": ["aioecowitt==2025.9.2"]
}
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==3.0.1"],
"requirements": ["pyenphase==3.0.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
+1 -6
View File
@@ -10,7 +10,6 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
NotTriggeredReasonReporter,
StatelessEntityTriggerBase,
Trigger,
TriggerConfig,
@@ -43,11 +42,7 @@ class EventReceivedTrigger(StatelessEntityTriggerBase):
self._event_types = set(self._options[CONF_EVENT_TYPE])
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
def is_valid_state(self, state: State) -> bool:
"""Check if the event type matches one of the configured types."""
return state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
+1 -1
View File
@@ -40,7 +40,7 @@ class FibaroScene(Scene):
self._attr_name = f"{room_name} {fibaro_scene.name}"
self._attr_unique_id = (
f"{slugify(controller.hub_serial)}.scene.{fibaro_scene.fibaro_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
f"{slugify(controller.hub_serial)}.scene.{fibaro_scene.fibaro_id}"
)
self._attr_extra_state_attributes = {"fibaro_id": fibaro_scene.fibaro_id}
# propagate hidden attribute set in fibaro home center to HA
+5 -14
View File
@@ -11,7 +11,7 @@ from typing import Any, TypedDict, cast, override
from xml.etree.ElementTree import ParseError
from fritzconnection import FritzConnection
from fritzconnection.core.exceptions import FritzActionError, FritzConnectionException
from fritzconnection.core.exceptions import FritzActionError
from fritzconnection.lib.fritzcall import FritzCall
from fritzconnection.lib.fritzhosts import FritzHosts
from fritzconnection.lib.fritzstatus import FritzStatus
@@ -267,7 +267,9 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
) = self._update_device_info()
if self.fritz_status.has_wan_support:
self.device_conn_type = self.fritz_status.connection_service
self.device_conn_type = (
self.fritz_status.get_default_connection_service().connection_service
)
self.device_is_router = self.fritz_status.has_wan_enabled
self.has_call_deflections = "X_AVM-DE_OnTel1" in self.connection.services
@@ -680,18 +682,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
async def async_trigger_reconnect(self) -> None:
"""Trigger device reconnect."""
try:
await self.hass.async_add_executor_job(
self.connection.call_action,
f"{self.device_conn_type}1",
"ForceTermination",
)
except FritzConnectionException as ex:
# ignore UPnPError:
# errorCode: 707
# errorDescription: DisconnectInProgress
if "disconnectinprogress" not in str(ex).lower():
raise
await self.hass.async_add_executor_job(self.connection.reconnect)
async def async_trigger_set_guest_password(
self, password: str | None, length: int
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260624.2"]
"requirements": ["home-assistant-frontend==20260624.1"]
}
@@ -34,7 +34,7 @@ class FullyCameraEntity(FullyKioskEntity, Camera):
"""Initialize the camera."""
FullyKioskEntity.__init__(self, coordinator)
Camera.__init__(self)
self._attr_unique_id = f"{coordinator.data['deviceID']}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{coordinator.data['deviceID']}-camera"
@override
async def async_camera_image(
@@ -2,8 +2,8 @@
import voluptuous as vol
from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback
from homeassistant.const import CONF_EVENT, CONF_PLATFORM
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
@@ -45,12 +45,9 @@ async def async_attach_trigger(
},
)
unsub: CALLBACK_TYPE | None = None
@callback
def hass_started(_: Event) -> None:
nonlocal unsub
unsub = None
# Automation are enabled while hass is starting up, fire right away
# Check state because a config reload shouldn't trigger it.
if trigger_info["home_assistant_start"]:
hass.async_run_hass_job(
job,
{
@@ -63,13 +60,4 @@ async def async_attach_trigger(
},
)
# Only fires if armed before EVENT_HOMEASSISTANT_STARTED; if hass is already
# started, the trigger doesn't fire.
unsub = hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, hass_started)
@callback
def remove() -> None:
if unsub is not None:
unsub()
return remove
return lambda: None
@@ -88,7 +88,7 @@ class WaitingAddonManager(AddonManager):
info = None
# Do not try to uninstall an addon if it is already uninstalled
if info is not None and info.state is AddonState.NOT_INSTALLED:
if info is not None and info.state == AddonState.NOT_INSTALLED:
return
await self.async_uninstall_addon()
@@ -10,7 +10,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_OEM, DEFAULT_OEM
from .coordinator import HypontechConfigEntry, HypontechDataCoordinator
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: HypontechConfigEntry) -> bool:
@@ -1,56 +0,0 @@
"""The binary sensors for Hypontech integration."""
from typing import override
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HypontechConfigEntry, HypontechDataCoordinator
from .entity import HypontechPlantEntity
PLANT_STATUS_BINARY_SENSOR_DESCRIPTION = BinarySensorEntityDescription(
key="status",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HypontechConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary sensor platform."""
coordinator = config_entry.runtime_data
async_add_entities(
HypontechPlantStatusBinarySensor(coordinator, plant_id)
for plant_id in coordinator.data.plants
)
class HypontechPlantStatusBinarySensor(HypontechPlantEntity, BinarySensorEntity):
"""Class describing Hypontech plant status binary sensor entity."""
entity_description: BinarySensorEntityDescription
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
coordinator: HypontechDataCoordinator,
plant_id: str,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator, plant_id)
self.entity_description = PLANT_STATUS_BINARY_SENSOR_DESCRIPTION
self._attr_unique_id = f"{plant_id}_{self.entity_description.key}"
@property
@override
def is_on(self) -> bool:
"""Return the state of the binary sensor."""
return self.plant.info.status == "online"
+1 -1
View File
@@ -36,7 +36,7 @@ class ImmichUpdateEntity(ImmichEntity, UpdateEntity):
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_update"
@property
@override
@@ -1 +0,0 @@
"""Virtual integration: IoTorero."""
@@ -1,6 +0,0 @@
{
"domain": "iotorero",
"name": "IoTorero",
"integration_type": "virtual",
"supported_by": "esphome"
}
+8 -7
View File
@@ -18,10 +18,13 @@ from homeassistant.components.climate import (
from homeassistant.components.lock import LockState
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
CURRENCY_CENT,
CURRENCY_DOLLAR,
DEGREE,
LIGHT_LUX,
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
SERVICE_LOCK,
SERVICE_UNLOCK,
@@ -37,7 +40,6 @@ from homeassistant.const import (
UV_INDEX,
Platform,
UnitOfApparentPower,
UnitOfDensity,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
@@ -47,7 +49,6 @@ from homeassistant.const import (
UnitOfMass,
UnitOfPower,
UnitOfPressure,
UnitOfRatio,
UnitOfReactivePower,
UnitOfSoundPressure,
UnitOfSpeed,
@@ -340,8 +341,8 @@ UOM_FRIENDLY_NAME = {
"18": UnitOfLength.FEET,
"19": UnitOfTime.HOURS,
"20": UnitOfTime.HOURS,
"21": UnitOfRatio.PERCENTAGE,
"22": UnitOfRatio.PERCENTAGE,
"21": PERCENTAGE,
"22": PERCENTAGE,
"23": UnitOfPressure.INHG,
"24": UnitOfVolumetricFlux.INCHES_PER_HOUR,
UOM_INDEX: UOM_INDEX, # Index type. Use "node.formatted" for value
@@ -370,10 +371,10 @@ UOM_FRIENDLY_NAME = {
"48": UnitOfSpeed.MILES_PER_HOUR,
"49": UnitOfSpeed.METERS_PER_SECOND,
"50": "",
UOM_PERCENTAGE: UnitOfRatio.PERCENTAGE,
UOM_PERCENTAGE: PERCENTAGE,
"52": UnitOfMass.POUNDS,
"53": "pf",
"54": UnitOfRatio.PARTS_PER_MILLION,
"54": CONCENTRATION_PARTS_PER_MILLION,
"55": "pulse count",
"57": UnitOfTime.SECONDS,
"58": UnitOfTime.SECONDS,
@@ -422,7 +423,7 @@ UOM_FRIENDLY_NAME = {
"118": UnitOfPressure.HPA,
"119": UnitOfEnergy.WATT_HOUR,
"120": UnitOfVolumetricFlux.INCHES_PER_DAY,
"122": UnitOfDensity.MICROGRAMS_PER_CUBIC_METER, # Microgram per cubic meter
"122": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # Microgram per cubic meter
"123": f"bq/{UnitOfVolume.CUBIC_METERS}", # Becquerel per cubic meter
"124": f"pCi/{UnitOfVolume.LITERS}", # Picocuries per liter
"125": "pH",
@@ -108,7 +108,7 @@ class KaiterraAirQuality(AirQualityEntity):
@override
def unique_id(self):
"""Return the sensor's unique id."""
return f"{self._device_id}_air_quality" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
return f"{self._device_id}_air_quality"
@property
@override
+13 -6
View File
@@ -2,7 +2,14 @@
from datetime import timedelta
from homeassistant.const import Platform, UnitOfDensity, UnitOfRatio
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
Platform,
)
DOMAIN = "kaiterra"
@@ -48,13 +55,13 @@ ATTR_AQI_POLLUTANT = "air_quality_index_pollutant"
AVAILABLE_AQI_STANDARDS = ["us", "cn", "in"]
AVAILABLE_UNITS = [
"x",
UnitOfRatio.PERCENTAGE,
PERCENTAGE,
"C",
"F",
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
UnitOfRatio.PARTS_PER_MILLION,
UnitOfRatio.PARTS_PER_BILLION,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_PARTS_PER_BILLION,
]
AVAILABLE_DEVICE_TYPES = ["laseregg", "sensedge"]
+1 -1
View File
@@ -114,7 +114,7 @@ async def _get_coordinator(
for entry_id in device.config_entries:
entry = call.hass.config_entries.async_get_entry(entry_id)
if entry and entry.domain == DOMAIN:
if entry.state is not ConfigEntryState.LOADED:
if entry.state != ConfigEntryState.LOADED:
raise HomeAssistantError(f"{entry.title} is not loaded")
return entry.runtime_data
@@ -237,7 +237,7 @@ class DemoWeather(WeatherEntity):
) -> None:
"""Initialize the Demo weather."""
self._attr_name = f"Test Weather {name}"
self._attr_unique_id = f"test-weather-{name.lower()}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"test-weather-{name.lower()}"
self._condition = condition
self._native_temperature = temperature
self._native_temperature_unit = temperature_unit
+1 -1
View File
@@ -33,7 +33,7 @@ class LaMetricUpdate(LaMetricEntity, UpdateEntity):
def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.data.serial_number}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{coordinator.data.serial_number}-update"
@property
@override
+1 -1
View File
@@ -69,7 +69,7 @@ class LiebherrPresentationLight(LiebherrEntity, LightEntity):
) -> None:
"""Initialize the presentation light entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.device_id}_presentation_light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{coordinator.device_id}_presentation_light"
@property
def _light_control(self) -> PresentationLightControl | None:
@@ -42,7 +42,7 @@ class LutronCasetaScene(Scene):
identifiers={(DOMAIN, data.bridge_device["serial"])},
)
self._attr_name = scene["name"]
self._attr_unique_id = f"scene_{bridge_unique_id}_{self._scene_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"scene_{bridge_unique_id}_{self._scene_id}"
@override
async def async_activate(self, **kwargs: Any) -> None:
@@ -24,8 +24,6 @@ ATTR_MEDIA_DESCRIPTION = "media_description"
ATTR_LANGUAGE = "language"
ATTR_DURATION = "duration"
ATTR_HIDE_NOTIFICATIONS = "hide_notifications"
ATTR_IN_REPLY_TO = "in_reply_to"
ATTR_QUOTED_STATUS = "quoted_status"
ATTR_DISPLAY_NAME = "display_name"
ATTR_NOTE = "note"
@@ -48,14 +48,12 @@ from .const import (
ATTR_HEADER_MIME_TYPE,
ATTR_HIDE_NOTIFICATIONS,
ATTR_IDEMPOTENCY_KEY,
ATTR_IN_REPLY_TO,
ATTR_LANGUAGE,
ATTR_MEDIA,
ATTR_MEDIA_DESCRIPTION,
ATTR_MEDIA_WARNING,
ATTR_NOTE,
ATTR_QUOTE_APPROVAL_POLICY,
ATTR_QUOTED_STATUS,
ATTR_STATUS,
ATTR_VALUE,
ATTR_VISIBILITY,
@@ -128,8 +126,6 @@ SERVICE_POST_SCHEMA = vol.Schema(
vol.Optional(ATTR_MEDIA): str,
vol.Optional(ATTR_MEDIA_DESCRIPTION): str,
vol.Optional(ATTR_MEDIA_WARNING): bool,
vol.Optional(ATTR_IN_REPLY_TO): str,
vol.Optional(ATTR_QUOTED_STATUS): str,
}
)
@@ -317,8 +313,6 @@ async def _async_post(call: ServiceCall) -> ServiceResponse:
media_path: str | None = call.data.get(ATTR_MEDIA)
media_description: str | None = call.data.get(ATTR_MEDIA_DESCRIPTION)
media_warning: str | None = call.data.get(ATTR_MEDIA_WARNING)
in_reply_to: str | None = call.data.get(ATTR_IN_REPLY_TO)
quoted_status: str | None = call.data.get(ATTR_QUOTED_STATUS)
if idempotency_key and len(idempotency_key) < 4:
raise ServiceValidationError(
@@ -340,8 +334,6 @@ async def _async_post(call: ServiceCall) -> ServiceResponse:
media_path=media_path,
media_description=media_description,
sensitive=media_warning,
in_reply_to_id=in_reply_to,
quoted_status_id=quoted_status,
)
)
@@ -278,14 +278,6 @@ post:
required: true
selector:
boolean:
in_reply_to:
required: false
selector:
text:
quoted_status:
required: false
selector:
text:
update_profile:
fields:
config_entry_id: *config_entry_id
@@ -219,10 +219,6 @@
"description": "A unique key for this post. If specified then subsequent posts with the same key will be ignored by your Mastodon instance. Mastodon holds keys for up to one hour (default: no idempotency key, which allows duplication).",
"name": "Idempotency key"
},
"in_reply_to": {
"description": "The ID of the status to reply to. Setting this makes the new post a reply, allowing you to create or continue a thread.",
"name": "In reply to"
},
"language": {
"description": "The language of the post (default: Mastodon account preference).",
"name": "Language"
@@ -243,10 +239,6 @@
"description": "Who can quote this post (default: account setting).\nIgnored if visibility is private or direct.",
"name": "Who can quote"
},
"quoted_status": {
"description": "The ID of the status to quote in this post.",
"name": "Quote"
},
"status": {
"description": "The status to post.",
"name": "Status"
@@ -9,7 +9,6 @@ from homeassistant.helpers.trigger import (
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
EntityTriggerBase,
NotTriggeredReasonReporter,
Trigger,
make_entity_transition_trigger,
)
@@ -61,11 +60,7 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
return self.is_muted(from_state) != self.is_muted(to_state)
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state."""
if not self._has_volume_attributes(state):
return False
@@ -227,8 +227,8 @@ class MikrotikData:
_LOGGER.debug("Running command %s", cmd)
try:
if params:
return list(self.api(cmd, **params))
return list(self.api(cmd))
return list(self.api(cmd=cmd, **params))
return list(self.api(cmd=cmd))
except (
librouteros.exceptions.ConnectionClosed,
OSError,
@@ -318,7 +318,8 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api:
"""Connect to Mikrotik hub."""
_LOGGER.debug("Connecting to Mikrotik hub [%s]", entry[CONF_HOST])
kwargs = {"port": entry["port"], "encoding": "utf8"}
_login_method = (login_plain, login_token)
kwargs = {"login_methods": _login_method, "port": entry["port"], "encoding": "utf8"}
if entry[CONF_VERIFY_SSL]:
ssl_context = ssl.create_default_context()
@@ -327,30 +328,22 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api:
_ssl_wrapper = ssl_context.wrap_socket
kwargs["ssl_wrapper"] = _ssl_wrapper
_error: Exception | None = None
for method in (login_plain, login_token):
try:
kwargs["login_method"] = method
api = librouteros.connect(
entry[CONF_HOST],
entry[CONF_USERNAME],
entry[CONF_PASSWORD],
**kwargs,
)
_error = None
break
except (
librouteros.exceptions.LibRouterosError,
OSError,
TimeoutError,
) as api_error:
_error = api_error
if _error is not None:
_LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], _error)
if "invalid user name or password" in str(_error):
raise LoginError from _error
raise CannotConnect from _error
try:
api = librouteros.connect(
entry[CONF_HOST],
entry[CONF_USERNAME],
entry[CONF_PASSWORD],
**kwargs,
)
except (
librouteros.exceptions.LibRouterosError,
OSError,
TimeoutError,
) as api_error:
_LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], api_error)
if "invalid user name or password" in str(api_error):
raise LoginError from api_error
raise CannotConnect from api_error
_LOGGER.debug("Connected to %s successfully", entry[CONF_HOST])
return api
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["librouteros"],
"requirements": ["librouteros==4.1.1"]
"requirements": ["librouteros==3.2.1"]
}
+2 -1
View File
@@ -71,6 +71,7 @@ from .const import (
DEFAULT_QOS,
DEFAULT_TRANSPORT,
DEFAULT_WILL,
DEFAULT_WS_HEADERS,
DEFAULT_WS_PATH,
DOMAIN,
MQTT_CONNECTION_STATE,
@@ -413,7 +414,7 @@ class MqttClientSetup:
tls_insecure = config.get(CONF_TLS_INSECURE)
if transport == TRANSPORT_WEBSOCKETS:
ws_path: str = config.get(CONF_WS_PATH, DEFAULT_WS_PATH)
ws_headers: dict[str, str] = config.get(CONF_WS_HEADERS, {})
ws_headers: dict[str, str] = config.get(CONF_WS_HEADERS, DEFAULT_WS_HEADERS)
self._client.ws_set_options(ws_path, ws_headers)
if certificate is not None:
self._client.tls_set(
+368 -254
View File
@@ -373,6 +373,7 @@ from .const import (
DEFAULT_CLIMATE_INITIAL_TEMPERATURE,
DEFAULT_DISCOVERY,
DEFAULT_ENCODING,
DEFAULT_KEEPALIVE,
DEFAULT_ON_COMMAND_TYPE,
DEFAULT_PAYLOAD_ARM_AWAY,
DEFAULT_PAYLOAD_ARM_CUSTOM_BYPASS,
@@ -413,6 +414,7 @@ from .const import (
DEFAULT_TILT_OPEN_POSITION,
DEFAULT_TRANSPORT,
DEFAULT_WILL,
DEFAULT_WS_PATH,
DOMAIN,
REMOTE_CODE,
REMOTE_CODE_TEXT,
@@ -439,7 +441,7 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 5
CONF_CLIENT_KEY_PASSWORD = "client_key_password"
OTHER_SETTINGS = "other_settings"
ADVANCED_OPTIONS = "advanced_options"
SET_CA_CERT = "set_ca_cert"
SET_CLIENT_CERT = "set_client_cert"
@@ -1122,7 +1124,7 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]:
if user_data.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) >= user_data.get(
CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN
):
errors[OTHER_SETTINGS] = "max_below_min_kelvin"
errors["other_settings"] = "max_below_min_kelvin"
return errors
@@ -1504,7 +1506,7 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=SUGGESTED_DISPLAY_PRECISION_SELECTOR,
required=False,
validator=cv.positive_int,
section=OTHER_SETTINGS,
section="other_settings",
),
CONF_OPTIONS: PlatformField(
selector=OPTIONS_SELECTOR,
@@ -1676,13 +1678,13 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=TIMEOUT_SELECTOR,
required=False,
validator=cv.positive_int,
section=OTHER_SETTINGS,
section="other_settings",
),
CONF_OFF_DELAY: PlatformField(
selector=TIMEOUT_SELECTOR,
required=False,
validator=cv.positive_int,
section=OTHER_SETTINGS,
section="other_settings",
),
},
Platform.BUTTON: {
@@ -3123,7 +3125,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
default=False,
validator=cv.boolean,
conditions=({CONF_SCHEMA: "json"},),
section=OTHER_SETTINGS,
section="other_settings",
),
CONF_FLASH_TIME_SHORT: PlatformField(
selector=FLASH_TIME_SELECTOR,
@@ -3131,7 +3133,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
validator=cv.positive_int,
default=2,
conditions=({CONF_SCHEMA: "json"},),
section=OTHER_SETTINGS,
section="other_settings",
),
CONF_FLASH_TIME_LONG: PlatformField(
selector=FLASH_TIME_SELECTOR,
@@ -3139,7 +3141,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
validator=cv.positive_int,
default=10,
conditions=({CONF_SCHEMA: "json"},),
section=OTHER_SETTINGS,
section="other_settings",
),
CONF_TRANSITION: PlatformField(
selector=BOOLEAN_SELECTOR,
@@ -3147,21 +3149,21 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
default=False,
validator=cv.boolean,
conditions=({CONF_SCHEMA: "json"},),
section=OTHER_SETTINGS,
section="other_settings",
),
CONF_MAX_KELVIN: PlatformField(
selector=KELVIN_SELECTOR,
required=False,
validator=cv.positive_int,
default=DEFAULT_MAX_KELVIN,
section=OTHER_SETTINGS,
section="other_settings",
),
CONF_MIN_KELVIN: PlatformField(
selector=KELVIN_SELECTOR,
required=False,
validator=cv.positive_int,
default=DEFAULT_MIN_KELVIN,
section=OTHER_SETTINGS,
section="other_settings",
),
},
Platform.LOCK: {
@@ -3370,7 +3372,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=TIMEOUT_SELECTOR,
required=False,
validator=cv.positive_int,
section=OTHER_SETTINGS,
section="other_settings",
),
},
Platform.SIREN: {
@@ -3796,10 +3798,10 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
MQTT_DEVICE_PLATFORM_FIELDS = {
CONF_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
CONF_SW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, section=OTHER_SETTINGS
selector=TEXT_SELECTOR, required=False, section="other_settings"
),
CONF_HW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, section=OTHER_SETTINGS
selector=TEXT_SELECTOR, required=False, section="other_settings"
),
CONF_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
CONF_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
@@ -4034,22 +4036,24 @@ def subentry_schema_default_data_from_fields(
@callback
def update_password_from_user_input(
entry_password: str | None, user_input: dict[str, Any]
) -> None:
) -> dict[str, Any]:
"""Update the password if the entry has been updated.
As we want to avoid reflecting the stored password in the UI,
we replace the suggested value in the UI with a sentitel,
and we change it back here if it was changed.
"""
substituted_used_data = dict(user_input)
# Take out the password submitted
user_password: str | None = user_input.pop(CONF_PASSWORD, None)
user_password: str | None = substituted_used_data.pop(CONF_PASSWORD, None)
# Only add the password if it has changed.
# If the sentinel password is submitted, we replace that with our current
# password from the config entry data.
password_changed = user_password is not None and user_password != PWD_NOT_CHANGED
password = user_password if password_changed else entry_password
if password is not None:
user_input[CONF_PASSWORD] = password
substituted_used_data[CONF_PASSWORD] = password
return substituted_used_data
REAUTH_SCHEMA = vol.Schema(
@@ -4059,35 +4063,6 @@ REAUTH_SCHEMA = vol.Schema(
}
)
OTHER_SETTINGS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_CLIENT_ID): TEXT_SELECTOR,
vol.Optional(CONF_KEEPALIVE): KEEPALIVE_SELECTOR,
vol.Required(SET_CLIENT_CERT): BOOLEAN_SELECTOR,
vol.Optional(CONF_CLIENT_CERT): CERT_UPLOAD_SELECTOR,
vol.Optional(CONF_CLIENT_KEY): CERT_KEY_UPLOAD_SELECTOR,
vol.Optional(CONF_CLIENT_KEY_PASSWORD): PASSWORD_SELECTOR,
vol.Required(SET_CA_CERT): BROKER_VERIFICATION_SELECTOR,
vol.Optional(CONF_CERTIFICATE): CA_CERT_UPLOAD_SELECTOR,
vol.Optional(CONF_TLS_INSECURE): BOOLEAN_SELECTOR,
vol.Required(CONF_TRANSPORT, default=DEFAULT_TRANSPORT): TRANSPORT_SELECTOR,
vol.Optional(CONF_WS_PATH): TEXT_SELECTOR,
vol.Optional(CONF_WS_HEADERS): WS_HEADERS_SELECTOR,
}
)
CONFIG_DATAFLOW_SCHEMA = vol.Schema(
{
vol.Required(CONF_BROKER): TEXT_SELECTOR,
vol.Required(CONF_PORT, default=DEFAULT_PORT): PORT_SELECTOR,
vol.Required(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): PROTOCOL_SELECTOR,
vol.Optional(CONF_USERNAME): TEXT_SELECTOR,
vol.Optional(CONF_PASSWORD): PASSWORD_SELECTOR,
vol.Required(OTHER_SETTINGS): section(
OTHER_SETTINGS_SCHEMA, SectionConfig({"collapsed": True})
),
}
)
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
@@ -4097,26 +4072,24 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
_hassio_discovery: dict[str, Any] | None = None
_addon_manager: AddonManager
last_uploaded: dict[str, Any]
def __init__(self) -> None:
"""Set up flow instance."""
self.install_task: asyncio.Task | None = None
self.start_task: asyncio.Task | None = None
self.last_uploaded = {}
@override
@classmethod
@callback
@override
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {CONF_DEVICE: MQTTSubentryFlowHandler}
@override
@staticmethod
@callback
@override
def async_get_options_flow(
config_entry: ConfigEntry,
) -> MQTTOptionsFlowHandler:
@@ -4337,9 +4310,8 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
reauth_entry = self._get_reauth_entry()
if user_input:
substituted_used_data = deepcopy(user_input)
update_password_from_user_input(
reauth_entry.data.get(CONF_PASSWORD), substituted_used_data
substituted_used_data = update_password_from_user_input(
reauth_entry.data.get(CONF_PASSWORD), user_input
)
new_entry_data = {**reauth_entry.data, **substituted_used_data}
if await self.hass.async_add_executor_job(
@@ -4363,76 +4335,49 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
@callback
def async_get_entry_defaults(self) -> dict[str, Any]:
"""Load the default settings from the entry."""
data = self._get_reconfigure_entry().data
other_settings: dict[str, Any] = {
key.schema: data[key.schema]
for key in OTHER_SETTINGS_SCHEMA.schema
if key in data
}
other_settings[SET_CLIENT_CERT] = (CONF_CLIENT_CERT in other_settings) and (
CONF_CLIENT_KEY in other_settings
)
other_settings.pop(CONF_CLIENT_CERT, None)
other_settings.pop(CONF_CLIENT_KEY, None)
conf_cert = other_settings.pop(CONF_CERTIFICATE, None)
other_settings[SET_CA_CERT] = (
"auto"
if conf_cert == "auto"
else "custom"
if conf_cert is not None
else "off"
)
if CONF_WS_HEADERS in other_settings:
other_settings[CONF_WS_HEADERS] = json_dumps(
other_settings.pop(CONF_WS_HEADERS)
)
settings: dict[str, Any] = {
key.schema: data[key.schema]
for key in CONFIG_DATAFLOW_SCHEMA.schema
if key in data
}
settings[OTHER_SETTINGS] = other_settings
if CONF_PASSWORD in settings:
# Hide entry password
settings[CONF_PASSWORD] = PWD_NOT_CHANGED
return settings
async def async_step_broker(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup."""
errors: dict[str, str] = {}
schema = CONFIG_DATAFLOW_SCHEMA
entry_config_update: dict[str, Any] = {}
entry_defaults: dict[str, Any] | None = None
fields: OrderedDict[Any, Any] = OrderedDict()
validated_user_input: dict[str, Any] = {}
if is_reconfigure := (self.source == SOURCE_RECONFIGURE):
reconfigure_entry = self._get_reconfigure_entry()
entry_defaults = self.async_get_entry_defaults()
if await async_validate_broker_settings(
if await async_get_broker_settings(
self,
fields,
reconfigure_entry.data if is_reconfigure else None,
user_input,
entry_config_update,
validated_user_input,
errors,
):
if is_reconfigure:
return self.async_update_and_abort(
reconfigure_entry,
data=entry_config_update,
validated_user_input = update_password_from_user_input(
reconfigure_entry.data.get(CONF_PASSWORD), validated_user_input
)
return self.async_create_entry(
title=entry_config_update[CONF_BROKER],
data=entry_config_update,
can_connect = await self.hass.async_add_executor_job(
try_connection,
validated_user_input,
)
schema = self.add_suggested_values_to_schema(
schema, (entry_defaults or {}) | (user_input or {})
if can_connect:
if is_reconfigure:
return self.async_update_and_abort(
reconfigure_entry,
data=validated_user_input,
)
return self.async_create_entry(
title=validated_user_input[CONF_BROKER],
data=validated_user_input,
)
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="broker", data_schema=vol.Schema(fields), errors=errors
)
return self.async_show_form(step_id="broker", data_schema=schema, errors=errors)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
@@ -4743,8 +4688,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
if user_input is not None:
new_device_data: dict[str, Any] = user_input.copy()
_, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS)
if OTHER_SETTINGS in new_device_data:
new_device_data |= new_device_data.pop(OTHER_SETTINGS)
if "other_settings" in new_device_data:
new_device_data |= new_device_data.pop("other_settings")
if not errors:
self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data)
if self.source == SOURCE_RECONFIGURE:
@@ -5305,162 +5250,331 @@ async def _get_uploaded_file(hass: HomeAssistant, id: str) -> bytes:
return await hass.async_add_executor_job(_proces_uploaded_file)
async def async_validate_broker_settings(
flow: FlowHandler,
entry_config: MappingProxyType[str, Any] | None,
user_input: dict[str, Any] | None,
entry_config_update: dict[str, Any],
errors: dict[str, str],
def _validate_pki_file(
file_id: str | None, pem_data: str | None, errors: dict[str, str], error: str
) -> bool:
"""Validate the broker settings, and return the updated entry dataset."""
async def _async_process_file_upload(
upload_id: str,
field: str,
pem_type: PEMType,
error_code: str,
password: str | None = None,
) -> bool:
"""Get uploaded file, or a preserved copy, and convert to a PEM file."""
try:
data_raw = await _get_uploaded_file(hass, upload_id)
except ValueError:
# Use preserved file if available.
# When an uploaded file was read, but an error occurs,
# the form will reload but the temporary file from the upload
# will not be available any more. If it was processed correctly,
# we can use the preserved copy.
if upload_id in flow.last_uploaded:
data_raw = flow.last_uploaded[upload_id]
else:
raise
else:
# Preserve a copy in case the validation fails,
# and we need it later
flow.last_uploaded[upload_id] = data_raw
pem_data = async_convert_to_pem(data_raw, pem_type, password)
if upload_id and not pem_data:
errors["base"] = error_code
return False
entry_config_update[field] = pem_data
return True
if user_input is None:
return False
hass = flow.hass
# Copy basic and other entry fields
entry_config_update |= user_input
entry_config_update.update(entry_config_update.pop(OTHER_SETTINGS))
# Pop incompatible fields for update
for key in (
SET_CA_CERT,
SET_CLIENT_CERT,
CONF_CERTIFICATE,
CONF_CLIENT_CERT,
CONF_CLIENT_KEY,
CONF_CLIENT_KEY_PASSWORD,
):
entry_config_update.pop(key, None)
# Get current CA certificate settings from config entry
if (set_ca_cert := user_input[OTHER_SETTINGS][SET_CA_CERT]) == "auto":
entry_config_update[CONF_CERTIFICATE] = "auto"
elif (
entry_config is not None
and set_ca_cert == "custom"
and (current_cert := entry_config.get(CONF_CERTIFICATE))
):
entry_config_update[CONF_CERTIFICATE] = current_cert
# Prepare entry update with uploaded certificate files
# converted to PEM format
new_client_certificate: str | None = user_input[OTHER_SETTINGS].get(
CONF_CLIENT_CERT
)
new_client_key: str | None = user_input[OTHER_SETTINGS].get(CONF_CLIENT_KEY)
set_client_cert = user_input[OTHER_SETTINGS][SET_CLIENT_CERT]
if (new_client_certificate and not new_client_key) or (
not new_client_certificate and new_client_key
):
errors["base"] = "invalid_inclusion"
return False
if new_certificate := user_input[OTHER_SETTINGS].get(CONF_CERTIFICATE):
if not await _async_process_file_upload(
new_certificate, CONF_CERTIFICATE, PEMType.CERTIFICATE, "bad_certificate"
):
return False
if new_client_certificate:
if not await _async_process_file_upload(
new_client_certificate,
CONF_CLIENT_CERT,
PEMType.CERTIFICATE,
"bad_client_cert",
):
return False
elif (
entry_config is not None
and set_client_cert
and (client_cert := entry_config.get(CONF_CLIENT_CERT))
):
entry_config_update[CONF_CLIENT_CERT] = client_cert
if new_client_key:
if not await _async_process_file_upload(
new_client_key,
CONF_CLIENT_KEY,
PEMType.PRIVATE_KEY,
"client_key_error",
password=user_input[OTHER_SETTINGS].get(CONF_CLIENT_KEY_PASSWORD),
):
return False
elif (
entry_config is not None
and set_client_cert
and (client_key := entry_config.get(CONF_CLIENT_KEY))
):
entry_config_update[CONF_CLIENT_KEY] = client_key
# We temporarily create the current and new uploaded certificate files
# and we check the certificate chain.
await async_create_certificate_temp_files(hass, entry_config_update)
if error := await hass.async_add_executor_job(
check_certicate_chain,
):
"""Return False if uploaded file could not be converted to PEM format."""
if file_id and not pem_data:
errors["base"] = error
return False
return True
if user_input[OTHER_SETTINGS].get(CONF_TRANSPORT, TRANSPORT_TCP) == TRANSPORT_TCP:
entry_config_update.pop(CONF_WS_PATH, None)
entry_config_update.pop(CONF_WS_HEADERS, None)
else:
# Web socket transport
try:
entry_config_update[CONF_WS_HEADERS] = json_loads(
user_input[OTHER_SETTINGS].get(CONF_WS_HEADERS, "{}")
async def async_get_broker_settings(
flow: ConfigFlow | OptionsFlow,
fields: OrderedDict[Any, Any],
entry_config: MappingProxyType[str, Any] | None,
user_input: dict[str, Any] | None,
validated_user_input: dict[str, Any],
errors: dict[str, str],
) -> bool:
"""Build the config flow schema to collect the broker settings.
Shows advanced options if one or more are configured
or when the advanced_broker_options checkbox was selected.
Returns True when settings are collected successfully.
"""
hass = flow.hass
advanced_broker_options: bool = False
user_input_basic: dict[str, Any] = {}
current_config: dict[str, Any] = (
entry_config.copy() if entry_config is not None else {}
)
async def _async_validate_broker_settings(
config: dict[str, Any],
user_input: dict[str, Any],
validated_user_input: dict[str, Any],
errors: dict[str, str],
) -> bool:
"""Additional validation on broker settings for better error messages."""
if CONF_PROTOCOL not in validated_user_input:
validated_user_input[CONF_PROTOCOL] = DEFAULT_PROTOCOL
# Get current certificate settings from config entry
certificate: str | None = (
"auto"
if user_input.get(SET_CA_CERT, "off") == "auto"
else config.get(CONF_CERTIFICATE)
if user_input.get(SET_CA_CERT, "off") == "custom"
else None
)
client_certificate: str | None = (
config.get(CONF_CLIENT_CERT) if user_input.get(SET_CLIENT_CERT) else None
)
client_key: str | None = (
config.get(CONF_CLIENT_KEY) if user_input.get(SET_CLIENT_CERT) else None
)
# Prepare entry update with uploaded files
validated_user_input.update(user_input)
client_certificate_id: str | None = user_input.get(CONF_CLIENT_CERT)
client_key_id: str | None = user_input.get(CONF_CLIENT_KEY)
# We do not store the private key password in the entry data
client_key_password: str | None = validated_user_input.pop(
CONF_CLIENT_KEY_PASSWORD, None
)
if (client_certificate_id and not client_key_id) or (
not client_certificate_id and client_key_id
):
errors["base"] = "invalid_inclusion"
return False
certificate_id: str | None = user_input.get(CONF_CERTIFICATE)
if certificate_id:
certificate_data_raw = await _get_uploaded_file(hass, certificate_id)
certificate = async_convert_to_pem(
certificate_data_raw, PEMType.CERTIFICATE
)
schema = vol.Schema({str: str})
schema(entry_config_update[CONF_WS_HEADERS])
if not _validate_pki_file(
certificate_id, certificate, errors, "bad_certificate"
):
return False
# Return to form for file upload CA cert or client cert and key
if (
(
not client_certificate
and user_input.get(SET_CLIENT_CERT)
and not client_certificate_id
)
or (
not certificate
and user_input.get(SET_CA_CERT, "off") == "custom"
and not certificate_id
)
or (
user_input.get(CONF_TRANSPORT) == TRANSPORT_WEBSOCKETS
and CONF_WS_PATH not in user_input
)
):
return False
if client_certificate_id:
client_certificate_data = await _get_uploaded_file(
hass, client_certificate_id
)
client_certificate = async_convert_to_pem(
client_certificate_data, PEMType.CERTIFICATE
)
if not _validate_pki_file(
client_certificate_id, client_certificate, errors, "bad_client_cert"
):
return False
if client_key_id:
client_key_data = await _get_uploaded_file(hass, client_key_id)
client_key = async_convert_to_pem(
client_key_data, PEMType.PRIVATE_KEY, password=client_key_password
)
if not _validate_pki_file(
client_key_id, client_key, errors, "client_key_error"
):
return False
certificate_data: dict[str, Any] = {}
if certificate:
certificate_data[CONF_CERTIFICATE] = certificate
if client_certificate:
certificate_data[CONF_CLIENT_CERT] = client_certificate
certificate_data[CONF_CLIENT_KEY] = client_key
validated_user_input.update(certificate_data)
await async_create_certificate_temp_files(hass, certificate_data)
if error := await hass.async_add_executor_job(
check_certicate_chain,
):
errors["base"] = error
return False
validated_user_input.pop(SET_CA_CERT, None)
validated_user_input.pop(SET_CLIENT_CERT, None)
if validated_user_input.get(CONF_TRANSPORT, TRANSPORT_TCP) == TRANSPORT_TCP:
validated_user_input.pop(CONF_WS_PATH, None)
validated_user_input.pop(CONF_WS_HEADERS, None)
return True
try:
validated_user_input[CONF_WS_HEADERS] = json_loads(
validated_user_input.get(CONF_WS_HEADERS, "{}")
)
schema = vol.Schema({cv.string: cv.template})
schema(validated_user_input[CONF_WS_HEADERS])
except (*JSON_DECODE_EXCEPTIONS, vol.MultipleInvalid):
errors["base"] = "bad_ws_headers"
return False
# Test the configuration
if entry_config is not None:
update_password_from_user_input(
entry_config.get(CONF_PASSWORD), entry_config_update
)
if await hass.async_add_executor_job(
try_connection,
entry_config_update,
):
return True
errors["base"] = "cannot_connect"
if user_input:
user_input_basic = user_input.copy()
advanced_broker_options = user_input_basic.get(ADVANCED_OPTIONS, False)
if ADVANCED_OPTIONS not in user_input or advanced_broker_options is False:
if await _async_validate_broker_settings(
current_config,
user_input_basic,
validated_user_input,
errors,
):
return True
# Get defaults settings from previous post
current_broker = user_input_basic.get(CONF_BROKER)
current_port = user_input_basic.get(CONF_PORT, DEFAULT_PORT)
current_user = user_input_basic.get(CONF_USERNAME)
current_pass = user_input_basic.get(CONF_PASSWORD)
else:
# Get default settings from entry (if any)
current_broker = current_config.get(CONF_BROKER)
current_port = current_config.get(CONF_PORT, DEFAULT_PORT)
current_user = current_config.get(CONF_USERNAME)
# Return the sentinel password to avoid exposure
current_entry_pass = current_config.get(CONF_PASSWORD)
current_pass = PWD_NOT_CHANGED if current_entry_pass else None
# Treat the previous post as an update of the current settings
# (if there was a basic broker setup step)
current_config.update(user_input_basic)
# Get default settings for advanced broker options
current_client_id = current_config.get(CONF_CLIENT_ID)
current_keepalive = current_config.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE)
current_ca_certificate = current_config.get(CONF_CERTIFICATE)
current_client_certificate = current_config.get(CONF_CLIENT_CERT)
current_client_key = current_config.get(CONF_CLIENT_KEY)
current_tls_insecure = current_config.get(CONF_TLS_INSECURE, False)
current_protocol = current_config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)
current_transport = current_config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
current_ws_path = current_config.get(CONF_WS_PATH, DEFAULT_WS_PATH)
current_ws_headers = (
json_dumps(current_config.get(CONF_WS_HEADERS))
if CONF_WS_HEADERS in current_config
else None
)
advanced_broker_options |= bool(
current_client_id
or current_keepalive != DEFAULT_KEEPALIVE
or current_ca_certificate
or current_client_certificate
or current_client_key
or current_tls_insecure
or current_config.get(SET_CA_CERT, "off") != "off"
or current_config.get(SET_CLIENT_CERT)
or current_transport == TRANSPORT_WEBSOCKETS
)
# Build form
fields[vol.Required(CONF_BROKER, default=current_broker)] = TEXT_SELECTOR
fields[vol.Required(CONF_PORT, default=current_port)] = PORT_SELECTOR
fields[
vol.Optional(
CONF_PROTOCOL,
description={"suggested_value": current_protocol},
)
] = PROTOCOL_SELECTOR
fields[
vol.Optional(
CONF_USERNAME,
description={"suggested_value": current_user},
)
] = TEXT_SELECTOR
fields[
vol.Optional(
CONF_PASSWORD,
description={"suggested_value": current_pass},
)
] = PASSWORD_SELECTOR
# show advanced options checkbox if no defaults
# of the advanced options are overridden
if not advanced_broker_options:
fields[
vol.Optional(
ADVANCED_OPTIONS,
)
] = BOOLEAN_SELECTOR
return False
fields[
vol.Optional(
CONF_CLIENT_ID,
description={"suggested_value": current_client_id},
)
] = TEXT_SELECTOR
fields[
vol.Optional(
CONF_KEEPALIVE,
description={"suggested_value": current_keepalive},
)
] = KEEPALIVE_SELECTOR
fields[
vol.Optional(
SET_CLIENT_CERT,
default=current_client_certificate is not None
or current_config.get(SET_CLIENT_CERT) is True,
)
] = BOOLEAN_SELECTOR
if (
current_client_certificate is not None
or current_config.get(SET_CLIENT_CERT) is True
):
fields[
vol.Optional(
CONF_CLIENT_CERT,
description={"suggested_value": user_input_basic.get(CONF_CLIENT_CERT)},
)
] = CERT_UPLOAD_SELECTOR
fields[
vol.Optional(
CONF_CLIENT_KEY,
description={"suggested_value": user_input_basic.get(CONF_CLIENT_KEY)},
)
] = CERT_KEY_UPLOAD_SELECTOR
fields[
vol.Optional(
CONF_CLIENT_KEY_PASSWORD,
description={
"suggested_value": user_input_basic.get(CONF_CLIENT_KEY_PASSWORD)
},
)
] = PASSWORD_SELECTOR
verification_mode = current_config.get(SET_CA_CERT) or (
"off"
if current_ca_certificate is None
else "auto"
if current_ca_certificate == "auto"
else "custom"
)
fields[
vol.Optional(
SET_CA_CERT,
default=verification_mode,
)
] = BROKER_VERIFICATION_SELECTOR
if current_ca_certificate is not None or verification_mode == "custom":
fields[
vol.Optional(
CONF_CERTIFICATE,
user_input_basic.get(CONF_CERTIFICATE),
)
] = CA_CERT_UPLOAD_SELECTOR
fields[
vol.Optional(
CONF_TLS_INSECURE,
description={"suggested_value": current_tls_insecure},
)
] = BOOLEAN_SELECTOR
fields[
vol.Optional(
CONF_TRANSPORT,
description={"suggested_value": current_transport},
)
] = TRANSPORT_SELECTOR
if current_transport == TRANSPORT_WEBSOCKETS:
fields[
vol.Optional(CONF_WS_PATH, description={"suggested_value": current_ws_path})
] = TEXT_SELECTOR
fields[
vol.Optional(
CONF_WS_HEADERS, description={"suggested_value": current_ws_headers}
)
] = WS_HEADERS_SELECTOR
# Show form
return False
+1
View File
@@ -315,6 +315,7 @@ DEFAULT_TILT_MAX = 100
DEFAULT_TILT_MIN = 0
DEFAULT_TILT_OPEN_POSITION = 100
DEFAULT_TILT_OPTIMISTIC = False
DEFAULT_WS_HEADERS: dict[str, str] = {}
DEFAULT_WS_PATH = "/"
DEFAULT_POSITION_CLOSED = 0
DEFAULT_POSITION_OPEN = 100
+71 -36
View File
@@ -26,53 +26,46 @@
"step": {
"broker": {
"data": {
"advanced_options": "Advanced options",
"broker": "Broker",
"certificate": "Upload custom CA certificate file",
"client_cert": "Upload client certificate file",
"client_id": "Client ID (leave empty to randomly generated one)",
"client_key": "Upload private key file",
"client_key_password": "[%key:common::config_flow::data::password%]",
"keepalive": "The time between sending keep alive messages",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]",
"protocol": "MQTT protocol",
"username": "[%key:common::config_flow::data::username%]"
"set_ca_cert": "Broker certificate validation",
"set_client_cert": "Use a client certificate",
"tls_insecure": "Ignore broker certificate validation",
"transport": "MQTT transport",
"username": "[%key:common::config_flow::data::username%]",
"ws_headers": "WebSocket headers in JSON format",
"ws_path": "WebSocket path"
},
"data_description": {
"advanced_options": "Enable and select **Submit** to set advanced options.",
"broker": "The hostname or IP address of your MQTT broker.",
"certificate": "The custom CA certificate file to validate your MQTT broker's certificate.",
"client_cert": "The client certificate to authenticate against your MQTT broker.",
"client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.",
"client_key": "The private key file that belongs to your client certificate.",
"client_key_password": "The password for the private key file (if set).",
"keepalive": "A value less than 90 seconds is advised.",
"password": "The password to log in to your MQTT broker.",
"port": "The port your MQTT broker listens to. For example 1883.",
"protocol": "The MQTT protocol version that is used. Note that Home Assistant will silently change to version 5 if your broker supports it.",
"username": "The username to log in to your MQTT broker."
"set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT broker's certificate.",
"set_client_cert": "Enable and select **Next** to set a client certificate and private key to authenticate against your MQTT broker.",
"tls_insecure": "Option to ignore validation of your MQTT broker's certificate.",
"transport": "The transport to be used for the connection to your MQTT broker.",
"username": "The username to log in to your MQTT broker.",
"ws_headers": "The WebSocket headers to pass through the WebSocket-based connection to your MQTT broker.",
"ws_path": "The WebSocket path to be used for the connection to your MQTT broker."
},
"description": "Please enter the connection information of your MQTT broker.",
"sections": {
"other_settings": {
"data": {
"certificate": "Upload custom CA certificate file",
"client_cert": "Upload client certificate file",
"client_id": "Client ID (leave empty for a randomly generated one)",
"client_key": "Upload private key file",
"client_key_password": "[%key:common::config_flow::data::password%]",
"keepalive": "The time between sending keep alive messages",
"set_ca_cert": "Broker certificate validation",
"set_client_cert": "Use a client certificate",
"tls_insecure": "Ignore broker certificate validation",
"transport": "MQTT transport",
"ws_headers": "WebSocket headers in JSON format",
"ws_path": "WebSocket path"
},
"data_description": {
"certificate": "The custom CA certificate file to validate your MQTT broker's certificate.",
"client_cert": "The client certificate to authenticate against your MQTT broker.",
"client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.",
"client_key": "The private key file that belongs to your client certificate.",
"client_key_password": "The password for the private key file (if set).",
"keepalive": "A value less than 90 seconds is advised. Defaults to 60 seconds.",
"set_ca_cert": "When already set to **Custom**, a custom CA validation certificate is configured. Select **Auto** for automatic CA validation, or upload a custom CA certificate, to allow validating your MQTT broker's certificate.",
"set_client_cert": "When already selected, client certificate authentication is enabled. Upload a client certificate and key to enable.",
"tls_insecure": "Option to ignore validation of your MQTT broker's certificate.",
"transport": "The transport to be used for the connection to your MQTT broker.",
"ws_headers": "The WebSocket headers to pass through the WebSocket-based connection to your MQTT broker.",
"ws_path": "The WebSocket path to be used for the connection to your MQTT broker."
},
"name": "Other settings"
}
}
"description": "Please enter the connection information of your MQTT broker."
},
"hassio_confirm": {
"description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the {addon} app?",
@@ -1185,6 +1178,48 @@
"invalid_inclusion": "[%key:component::mqtt::config::error::invalid_inclusion%]"
},
"step": {
"broker": {
"data": {
"advanced_options": "[%key:component::mqtt::config::step::broker::data::advanced_options%]",
"broker": "[%key:component::mqtt::config::step::broker::data::broker%]",
"certificate": "[%key:component::mqtt::config::step::broker::data::certificate%]",
"client_cert": "[%key:component::mqtt::config::step::broker::data::client_cert%]",
"client_id": "[%key:component::mqtt::config::step::broker::data::client_id%]",
"client_key": "[%key:component::mqtt::config::step::broker::data::client_key%]",
"keepalive": "[%key:component::mqtt::config::step::broker::data::keepalive%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]",
"protocol": "[%key:component::mqtt::config::step::broker::data::protocol%]",
"set_ca_cert": "[%key:component::mqtt::config::step::broker::data::set_ca_cert%]",
"set_client_cert": "[%key:component::mqtt::config::step::broker::data::set_client_cert%]",
"tls_insecure": "[%key:component::mqtt::config::step::broker::data::tls_insecure%]",
"transport": "[%key:component::mqtt::config::step::broker::data::transport%]",
"username": "[%key:common::config_flow::data::username%]",
"ws_headers": "[%key:component::mqtt::config::step::broker::data::ws_headers%]",
"ws_path": "[%key:component::mqtt::config::step::broker::data::ws_path%]"
},
"data_description": {
"advanced_options": "[%key:component::mqtt::config::step::broker::data_description::advanced_options%]",
"broker": "[%key:component::mqtt::config::step::broker::data_description::broker%]",
"certificate": "[%key:component::mqtt::config::step::broker::data_description::certificate%]",
"client_cert": "[%key:component::mqtt::config::step::broker::data_description::client_cert%]",
"client_id": "[%key:component::mqtt::config::step::broker::data_description::client_id%]",
"client_key": "[%key:component::mqtt::config::step::broker::data_description::client_key%]",
"keepalive": "[%key:component::mqtt::config::step::broker::data_description::keepalive%]",
"password": "[%key:component::mqtt::config::step::broker::data_description::password%]",
"port": "[%key:component::mqtt::config::step::broker::data_description::port%]",
"protocol": "[%key:component::mqtt::config::step::broker::data_description::protocol%]",
"set_ca_cert": "[%key:component::mqtt::config::step::broker::data_description::set_ca_cert%]",
"set_client_cert": "[%key:component::mqtt::config::step::broker::data_description::set_client_cert%]",
"tls_insecure": "[%key:component::mqtt::config::step::broker::data_description::tls_insecure%]",
"transport": "[%key:component::mqtt::config::step::broker::data_description::transport%]",
"username": "[%key:component::mqtt::config::step::broker::data_description::username%]",
"ws_headers": "[%key:component::mqtt::config::step::broker::data_description::ws_headers%]",
"ws_path": "[%key:component::mqtt::config::step::broker::data_description::ws_path%]"
},
"description": "[%key:component::mqtt::config::step::broker::description%]",
"title": "Broker options"
},
"options": {
"data": {
"birth_enable": "Enable birth message",
+1 -1
View File
@@ -179,7 +179,7 @@ class TemperatureSensor(BaseSensorEntity):
"""Initialize TemperatureSensor entity."""
super().__init__(coordinator)
self._temp_sensor = nasweb_temp_sensor
self._attr_unique_id = f"{DOMAIN}.{self._temp_sensor.webio_serial}.temp_sensor" # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{DOMAIN}.{self._temp_sensor.webio_serial}.temp_sensor" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._temp_sensor.webio_serial)}
)
+1 -1
View File
@@ -96,7 +96,7 @@ class RelaySwitch(SwitchEntity, BaseCoordinatorEntity):
self._attr_translation_key = OUTPUT_TRANSLATION_KEY
self._attr_translation_placeholders = {"index": f"{nasweb_output.index:2d}"}
self._attr_unique_id = (
f"{DOMAIN}.{self._output.webio_serial}.relay_switch.{self._output.index}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
f"{DOMAIN}.{self._output.webio_serial}.relay_switch.{self._output.index}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._output.webio_serial)},
+1 -1
View File
@@ -151,7 +151,7 @@ class NestCameraBaseEntity(Camera, ABC):
self._attr_model = nest_device_info.device_model
self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3
# The API "name" field is a unique device identifier.
self._attr_unique_id = f"{self._device.name}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{self._device.name}-camera"
@override
async def async_added_to_hass(self) -> None:
+2 -2
View File
@@ -74,7 +74,7 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity):
def __init__(self, netatmo_device: NetatmoDevice) -> None:
"""Initialize a Netatmo Presence camera light."""
super().__init__(netatmo_device)
self._attr_unique_id = f"{self.device.entity_id}-light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{self.device.entity_id}-light"
self._signal_name = f"{HOME}-{self.home.entity_id}"
self._publishers.extend(
@@ -154,7 +154,7 @@ class NetatmoLight(NetatmoModuleEntity, LightEntity):
def __init__(self, netatmo_device: NetatmoDevice) -> None:
"""Initialize a Netatmo light."""
super().__init__(netatmo_device)
self._attr_unique_id = f"{self.device.entity_id}-light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{self.device.entity_id}-light"
if self.device.brightness is not None:
self._attr_color_mode = ColorMode.BRIGHTNESS
+1 -1
View File
@@ -69,7 +69,7 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity):
configuration_url=CONF_URL_ENERGY,
)
self._attr_unique_id = f"{self.home.entity_id}-schedule-select" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{self.home.entity_id}-schedule-select"
schedule = self.home.get_selected_schedule()
assert schedule
+1 -1
View File
@@ -43,7 +43,7 @@ class NetgearUpdateEntity(
) -> None:
"""Initialize a Netgear device."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.router.serial_number}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{coordinator.router.serial_number}-update"
@property
@override
+39 -34
View File
@@ -6,8 +6,15 @@ from typing import Final
import voluptuous as vol
from homeassistant.const import (
CONCENTRATION_GRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
DEGREE,
LIGHT_LUX,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfApparentPower,
@@ -15,7 +22,6 @@ from homeassistant.const import (
UnitOfBloodGlucoseConcentration,
UnitOfConductivity,
UnitOfDataRate,
UnitOfDensity,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
@@ -28,7 +34,6 @@ from homeassistant.const import (
UnitOfPower,
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfRatio,
UnitOfReactiveEnergy,
UnitOfReactivePower,
UnitOfSoundPressure,
@@ -511,22 +516,22 @@ class NumberDeviceClass(StrEnum):
DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass))
DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.ABSOLUTE_HUMIDITY: {
UnitOfDensity.GRAMS_PER_CUBIC_METER,
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_GRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower),
NumberDeviceClass.AQI: {None},
NumberDeviceClass.AREA: set(UnitOfArea),
NumberDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure),
NumberDeviceClass.BATTERY: {UnitOfRatio.PERCENTAGE},
NumberDeviceClass.BATTERY: {PERCENTAGE},
NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
NumberDeviceClass.CO: {
UnitOfRatio.PARTS_PER_BILLION,
UnitOfRatio.PARTS_PER_MILLION,
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.CO2: {UnitOfRatio.PARTS_PER_MILLION},
NumberDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION},
NumberDeviceClass.CONDUCTIVITY: set(UnitOfConductivity),
NumberDeviceClass.CURRENT: set(UnitOfElectricCurrent),
NumberDeviceClass.DATA_RATE: set(UnitOfDataRate),
@@ -551,31 +556,31 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
UnitOfVolume.LITERS,
UnitOfVolume.MILLE_CUBIC_FEET,
},
NumberDeviceClass.HUMIDITY: {UnitOfRatio.PERCENTAGE},
NumberDeviceClass.HUMIDITY: {PERCENTAGE},
NumberDeviceClass.ILLUMINANCE: {LIGHT_LUX},
NumberDeviceClass.IRRADIANCE: set(UnitOfIrradiance),
NumberDeviceClass.MOISTURE: {UnitOfRatio.PERCENTAGE},
NumberDeviceClass.MOISTURE: {PERCENTAGE},
NumberDeviceClass.NITROGEN_DIOXIDE: {
UnitOfRatio.PARTS_PER_BILLION,
UnitOfRatio.PARTS_PER_MILLION,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.NITROGEN_MONOXIDE: {
UnitOfRatio.PARTS_PER_BILLION,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.NITROUS_OXIDE: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.OZONE: {
UnitOfRatio.PARTS_PER_BILLION,
UnitOfRatio.PARTS_PER_MILLION,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.PH: {None},
NumberDeviceClass.PM1: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.PM10: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.PM25: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.PM4: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.POWER_FACTOR: {UnitOfRatio.PERCENTAGE, None},
NumberDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.PM4: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.POWER_FACTOR: {PERCENTAGE, None},
NumberDeviceClass.POWER: {
UnitOfPower.MILLIWATT,
UnitOfPower.WATT,
@@ -596,18 +601,18 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure),
NumberDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux},
NumberDeviceClass.SULPHUR_DIOXIDE: {
UnitOfRatio.PARTS_PER_BILLION,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.TEMPERATURE: set(UnitOfTemperature),
NumberDeviceClass.TEMPERATURE_DELTA: set(UnitOfTemperature),
NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {
UnitOfRatio.PARTS_PER_BILLION,
UnitOfRatio.PARTS_PER_MILLION,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
},
NumberDeviceClass.VOLTAGE: set(UnitOfElectricPotential),
NumberDeviceClass.VOLUME: set(UnitOfVolume),
@@ -676,8 +681,8 @@ AMBIGUOUS_UNITS: dict[str | None, str] = {
"\u00b5Sv/h": "μSv/h", # aranet: radiation rate
"\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM,
"\u00b5V": UnitOfElectricPotential.MICROVOLT,
"\u00b5g/ft³": UnitOfDensity.MICROGRAMS_PER_CUBIC_FOOT,
"\u00b5g/m³": UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
"\u00b5g/ft³": CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
"\u00b5g/m³": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light
"\u00b5g": UnitOfMass.MICROGRAMS,
"\u00b5s": UnitOfTime.MICROSECONDS,
+1 -1
View File
@@ -51,7 +51,7 @@ class OpenhomeUpdateEntity(UpdateEntity):
def __init__(self, device):
"""Initialize a Linn DS update entity."""
self._device = device
self._attr_unique_id = f"{device.uuid()}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{device.uuid()}-update"
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, device.uuid()),
@@ -217,7 +217,7 @@ class ConversationFlowHandler(ConfigSubentryFlow):
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Manage conversation agent configuration."""
if self._get_entry().state is not ConfigEntryState.LOADED:
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
if user_input is not None:
+1 -1
View File
@@ -80,7 +80,7 @@ class PlexSensor(SensorEntity):
def __init__(self, hass, plex_server):
"""Initialize the sensor."""
self._attr_unique_id = f"sensor-{plex_server.machine_identifier}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"sensor-{plex_server.machine_identifier}"
self._server = plex_server
self.async_refresh_sensor = Debouncer(
+1 -1
View File
@@ -102,7 +102,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
) -> None:
"""Set up the Plugwise API."""
super().__init__(coordinator, device_id)
self._attr_unique_id = f"{device_id}-climate" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{device_id}-climate"
self._api = coordinator.api
gateway_id: str = self._api.gateway_id
@@ -74,7 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) ->
translation_key="connect_failed",
) from err
if client_status is not RequestStatus.SUCCESS:
if client_status != RequestStatus.SUCCESS:
raise ConfigEntryNotReady(
translation_domain=entry.domain,
translation_key="client_init_failed",
@@ -62,7 +62,7 @@ class PooldoseCoordinator(DataUpdateCoordinator[StructuredValuesDict]):
translation_key="update_connect_failed",
) from err
if status is not RequestStatus.SUCCESS:
if status != RequestStatus.SUCCESS:
raise UpdateFailed(
translation_domain=self.config_entry.domain,
translation_key="api_status_error",
@@ -1132,7 +1132,7 @@ class PrometheusMetrics:
PERCENTAGE: "percent",
}
default = unit.replace("/", "_per_")
# Unit conversion for UnitOfDensity.MICROGRAMS_PER_CUBIC_METER "μg/m³"
# Unit conversion for CONCENTRATION_MICROGRAMS_PER_CUBIC_METER "μg/m³"
# "μ" == "\u03bc" but the API uses "\u00b5"
default = default.replace("\u03bc", "\u00b5")
default = default.lower()
+1 -1
View File
@@ -65,7 +65,7 @@ class RachioCalendarEntity(
self._attr_translation_placeholders = {
"base": coordinator.base_station[KEY_SERIAL_NUMBER]
}
self._attr_unique_id = f"{coordinator.base_station[KEY_ID]}-calendar" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{coordinator.base_station[KEY_ID]}-calendar"
self._previous_event: dict[str, Any] | None = None
@property
+1 -1
View File
@@ -40,7 +40,7 @@ class LeilSaunaLight(LeilSaunaEntity, LightEntity):
"""Initialize the light entity."""
super().__init__(coordinator)
# Override unique_id to differentiate from climate entity
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_light"
@property
@override
@@ -5,10 +5,11 @@ from screenlogicpy.device_const.circuit import FUNCTION
from screenlogicpy.device_const.system import COLOR_MODE
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
UnitOfElectricPotential,
UnitOfPower,
UnitOfRatio,
UnitOfTemperature,
UnitOfTime,
)
@@ -52,6 +53,6 @@ SL_UNIT_TO_HA_UNIT = {
UNIT.HOUR: UnitOfTime.HOURS,
UNIT.SECOND: UnitOfTime.SECONDS,
UNIT.REVOLUTIONS_PER_MINUTE: REVOLUTIONS_PER_MINUTE,
UNIT.PARTS_PER_MILLION: UnitOfRatio.PARTS_PER_MILLION,
UNIT.PERCENT: UnitOfRatio.PERCENTAGE,
UNIT.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION,
UNIT.PERCENT: PERCENTAGE,
}
+40 -35
View File
@@ -6,8 +6,15 @@ from typing import Final
import voluptuous as vol
from homeassistant.const import (
CONCENTRATION_GRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
DEGREE,
LIGHT_LUX,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfApparentPower,
@@ -15,7 +22,6 @@ from homeassistant.const import (
UnitOfBloodGlucoseConcentration,
UnitOfConductivity,
UnitOfDataRate,
UnitOfDensity,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
@@ -28,7 +34,6 @@ from homeassistant.const import (
UnitOfPower,
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfRatio,
UnitOfReactiveEnergy,
UnitOfReactivePower,
UnitOfSoundPressure,
@@ -630,22 +635,22 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.ABSOLUTE_HUMIDITY: {
UnitOfDensity.GRAMS_PER_CUBIC_METER,
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_GRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.APPARENT_POWER: set(UnitOfApparentPower),
SensorDeviceClass.AQI: {None},
SensorDeviceClass.AREA: set(UnitOfArea),
SensorDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure),
SensorDeviceClass.BATTERY: {UnitOfRatio.PERCENTAGE},
SensorDeviceClass.BATTERY: {PERCENTAGE},
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
SensorDeviceClass.CO: {
UnitOfRatio.PARTS_PER_BILLION,
UnitOfRatio.PARTS_PER_MILLION,
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.CO2: {UnitOfRatio.PARTS_PER_MILLION},
SensorDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION},
SensorDeviceClass.CONDUCTIVITY: set(UnitOfConductivity),
SensorDeviceClass.CURRENT: set(UnitOfElectricCurrent),
SensorDeviceClass.DATA_RATE: set(UnitOfDataRate),
@@ -670,31 +675,31 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
UnitOfVolume.LITERS,
UnitOfVolume.MILLE_CUBIC_FEET,
},
SensorDeviceClass.HUMIDITY: {UnitOfRatio.PERCENTAGE},
SensorDeviceClass.HUMIDITY: {PERCENTAGE},
SensorDeviceClass.ILLUMINANCE: {LIGHT_LUX},
SensorDeviceClass.IRRADIANCE: set(UnitOfIrradiance),
SensorDeviceClass.MOISTURE: {UnitOfRatio.PERCENTAGE},
SensorDeviceClass.MOISTURE: {PERCENTAGE},
SensorDeviceClass.NITROGEN_DIOXIDE: {
UnitOfRatio.PARTS_PER_BILLION,
UnitOfRatio.PARTS_PER_MILLION,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.NITROGEN_MONOXIDE: {
UnitOfRatio.PARTS_PER_BILLION,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.NITROUS_OXIDE: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.OZONE: {
UnitOfRatio.PARTS_PER_BILLION,
UnitOfRatio.PARTS_PER_MILLION,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.PH: {None},
SensorDeviceClass.PM1: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM10: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM25: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM4: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.POWER_FACTOR: {UnitOfRatio.PERCENTAGE, None},
SensorDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM4: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.POWER_FACTOR: {PERCENTAGE, None},
SensorDeviceClass.POWER: {
UnitOfPower.MILLIWATT,
UnitOfPower.WATT,
@@ -715,18 +720,18 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure),
SensorDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux},
SensorDeviceClass.SULPHUR_DIOXIDE: {
UnitOfRatio.PARTS_PER_BILLION,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.TEMPERATURE: set(UnitOfTemperature),
SensorDeviceClass.TEMPERATURE_DELTA: set(UnitOfTemperature),
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {
UnitOfRatio.PARTS_PER_BILLION,
UnitOfRatio.PARTS_PER_MILLION,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
},
SensorDeviceClass.VOLTAGE: set(UnitOfElectricPotential),
SensorDeviceClass.VOLUME: set(UnitOfVolume),
@@ -753,7 +758,7 @@ DEFAULT_PRECISION_LIMIT = 2
# have 0 decimals, that one should be used and not mW, even though mW also should have
# 0 decimals. Otherwise the smaller units will have more decimals than expected.
UNITS_PRECISION = {
SensorDeviceClass.ABSOLUTE_HUMIDITY: (UnitOfDensity.GRAMS_PER_CUBIC_METER, 1),
SensorDeviceClass.ABSOLUTE_HUMIDITY: (CONCENTRATION_GRAMS_PER_CUBIC_METER, 1),
SensorDeviceClass.APPARENT_POWER: (UnitOfApparentPower.VOLT_AMPERE, 0),
SensorDeviceClass.AREA: (UnitOfArea.SQUARE_CENTIMETERS, 0),
SensorDeviceClass.ATMOSPHERIC_PRESSURE: (UnitOfPressure.PA, 0),
@@ -886,8 +891,8 @@ AMBIGUOUS_UNITS: dict[str | None, str] = {
"\u00b5Sv/h": "μSv/h", # aranet: radiation rate
"\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM,
"\u00b5V": UnitOfElectricPotential.MICROVOLT,
"\u00b5g/ft³": UnitOfDensity.MICROGRAMS_PER_CUBIC_FOOT,
"\u00b5g/m³": UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
"\u00b5g/ft³": CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
"\u00b5g/m³": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light
"\u00b5g": UnitOfMass.MICROGRAMS,
"\u00b5s": UnitOfTime.MICROSECONDS,
+1 -1
View File
@@ -45,7 +45,7 @@ class SleepIQLightEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], LightEn
self.light = light
super().__init__(coordinator, bed)
self._attr_name = f"SleepNumber {bed.name} Light {light.outlet_id}"
self._attr_unique_id = f"{bed.id}-light-{light.outlet_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{bed.id}-light-{light.outlet_id}"
@override
async def async_turn_on(self, **kwargs: Any) -> None:
+1 -1
View File
@@ -37,7 +37,7 @@ class SmInfraredEntity(SmEntity, InfraredEmitterEntity):
def __init__(self, coordinator: SmDataUpdateCoordinator) -> None:
"""Initialize the SLZB-Ultima infrared."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-infrared-emitter" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_unique_id = f"{coordinator.unique_id}-infrared-emitter"
@override
async def async_send_command(self, command: InfraredCommand) -> None:
@@ -12,8 +12,8 @@
"documentation": "https://www.home-assistant.io/integrations/smlight",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "platinum",
"requirements": ["pysmlight==0.5.2", "bleak-smlight==1.1.0"],
"quality_scale": "silver",
"requirements": ["pysmlight==0.5.0", "bleak-smlight==1.1.0"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."
@@ -54,11 +54,11 @@ rules:
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
+1 -1
View File
@@ -42,7 +42,7 @@ from .coordinator import (
)
from .services import async_setup_services
PLATFORMS = [Platform.CALENDAR, Platform.SENSOR]
PLATFORMS = [Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -1,81 +0,0 @@
"""Support for Sonarr calendar items."""
from datetime import datetime, timedelta
from typing import cast, override
from aiopyarr import SonarrCalendar
from homeassistant.components.calendar import (
CalendarEntity,
CalendarEntityDescription,
CalendarEvent,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import CalendarDataUpdateCoordinator, SonarrConfigEntry
from .entity import SonarrEntity
CALENDAR_TYPE = CalendarEntityDescription(
key="calendar",
name=None,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SonarrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Sonarr calendar entity."""
coordinator = entry.runtime_data.upcoming
async_add_entities([SonarrCalendarEntity(coordinator, CALENDAR_TYPE)])
def _get_calendar_event(episode: SonarrCalendar) -> CalendarEvent:
"""Return a CalendarEvent from a Sonarr calendar item."""
series_title: str = episode.series.title # type: ignore[misc]
runtime: int = episode.series.runtime # type: ignore[misc]
start = dt_util.as_utc(episode.airDateUtc)
summary = (
f"{series_title} - S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}"
)
if episode.title:
summary = f"{summary} - {episode.title}"
return CalendarEvent(
summary=summary,
start=start,
end=start + timedelta(minutes=runtime),
description=getattr(episode, "overview", None) or None,
)
class SonarrCalendarEntity(SonarrEntity[list[SonarrCalendar]], CalendarEntity):
"""A Sonarr calendar entity."""
coordinator: CalendarDataUpdateCoordinator
@property
@override
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
now = dt_util.now()
events = sorted(
(_get_calendar_event(episode) for episode in self.coordinator.data),
key=lambda event: event.start,
)
return next((event for event in events if event.end > now), None)
@override
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
episodes = cast(
list[SonarrCalendar],
await self.coordinator.api_client.async_get_calendar(
start_date=start_date, end_date=end_date, include_series=True
),
)
return [_get_calendar_event(episode) for episode in episodes]
@@ -1,7 +1,6 @@
"""Config flow for Steam integration."""
from collections.abc import Iterator, Mapping
import logging
from typing import Any, override
import steam.api
@@ -18,12 +17,9 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, PLACEHOLDERS
from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, LOGGER, PLACEHOLDERS
from .coordinator import SteamConfigEntry
_LOGGER = logging.getLogger(__name__)
# To avoid too long request URIs, the amount of ids to request is limited
MAX_IDS_TO_REQUEST = 275
@@ -79,8 +75,8 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = (
"invalid_auth" if "403" in str(ex) else "cannot_connect"
)
except Exception:
_LOGGER.exception("Unknown exception")
except Exception: # noqa: BLE001
LOGGER.exception("Unknown exception")
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
@@ -133,8 +129,8 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = (
"invalid_auth" if "403" in str(ex) else "cannot_connect"
)
except Exception:
_LOGGER.exception("Unknown exception")
except Exception: # noqa: BLE001
LOGGER.exception("Unknown exception")
errors["base"] = "unknown"
if not errors:
@@ -170,6 +166,7 @@ class SteamOptionsFlowHandler(OptionsFlowWithReload):
) -> ConfigFlowResult:
"""Manage Steam options."""
if user_input is not None:
await self.hass.config_entries.async_unload(self.config_entry.entry_id)
for _id in self.options[CONF_ACCOUNTS]:
if _id not in user_input[CONF_ACCOUNTS] and (
entity_id := er.async_get(self.hass).async_get_entity_id(
@@ -1,5 +1,6 @@
"""Steam constants."""
import logging
from typing import Final
CONF_ACCOUNT = "account"
@@ -9,6 +10,7 @@ DATA_KEY_COORDINATOR = "coordinator"
DEFAULT_NAME = "Steam"
DOMAIN: Final = "steam_online"
LOGGER = logging.getLogger(__package__)
PLACEHOLDERS = {
"api_key_url": "https://steamcommunity.com/dev/apikey",
@@ -1,11 +1,10 @@
"""Data update coordinator for the Steam integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import override
import steam.api
from steam.api import _interface_method as INTMethod
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
@@ -13,116 +12,65 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_ACCOUNTS, DOMAIN
from .const import CONF_ACCOUNTS, DOMAIN, LOGGER
type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator]
_LOGGER = logging.getLogger(__name__)
@dataclass(kw_only=True, frozen=True)
class PlayerData:
"""Steam player data."""
steamid: str
communityvisibilitystate: int
profilestate: int
personaname: str
commentpermission: int | None = None
profileurl: str
avatar: str
avatarmedium: str
avatarfull: str
avatarhash: str
lastlogoff: int
personastate: int
realname: str | None = None
primaryclanid: str | None = None
timecreated: int | None = None
personastateflags: int
loccountrycode: str | None = None
locstatecode: str | None = None
loccityid: int | None = None
gameextrainfo: str | None = None
gameid: str | None = None
level: int | None = None
class SteamDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PlayerData]]):
class SteamDataUpdateCoordinator(
DataUpdateCoordinator[dict[str, dict[str, str | int]]]
):
"""Data update coordinator for the Steam integration."""
config_entry: SteamConfigEntry
user_interface: steam.api.interface
player_interface: steam.api.interface
def __init__(self, hass: HomeAssistant, config_entry: SteamConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass=hass,
logger=_LOGGER,
logger=LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(seconds=30),
)
self.game_icons: dict[str, str] = {}
@override
async def _async_setup(self) -> None:
"""Set up the coordinator."""
self.game_icons: dict[int, str] = {}
self.player_interface: INTMethod = None
self.user_interface: INTMethod = None
steam.api.key.set(self.config_entry.data[CONF_API_KEY])
self.user_interface = steam.api.interface("ISteamUser")
self.player_interface = steam.api.interface("IPlayerService")
def _update(self) -> dict[str, PlayerData]:
def _update(self) -> dict[str, dict[str, str | int]]:
"""Fetch data from API endpoint."""
accounts = self.config_entry.options[CONF_ACCOUNTS]
_ids = list(accounts)
if not self.user_interface or not self.player_interface:
self.user_interface = steam.api.interface("ISteamUser")
self.player_interface = steam.api.interface("IPlayerService")
if not self.game_icons:
for _id in _ids:
res = self.player_interface.GetOwnedGames(
steamid=_id, include_appinfo=1
)["response"]
self.game_icons = self.game_icons | {
game["appid"]: game["img_icon_url"] for game in res.get("games", [])
}
response = self.user_interface.GetPlayerSummaries(steamids=_ids)
players = {
player["steamid"]: PlayerData(
**player,
level=self.player_interface.GetSteamLevel(steamid=player["steamid"])[
"response"
].get("player_level"),
)
player["steamid"]: player
for player in response["response"]["players"]["player"]
if player["steamid"] in _ids
}
for player in players.values():
if player.gameid and player.gameid not in self.game_icons:
games = self.player_interface.GetOwnedGames(
steamid=player.steamid,
include_appinfo=1,
include_played_free_games=True,
)["response"].get("games", [])
self.game_icons.update(
{str(game["appid"]): game["img_icon_url"] for game in games}
)
for value in players.values():
data = self.player_interface.GetSteamLevel(steamid=value["steamid"])
value["level"] = data["response"].get("player_level")
return players
@override
async def _async_update_data(self) -> dict[str, PlayerData]:
async def _async_update_data(self) -> dict[str, dict[str, str | int]]:
"""Send request to the executor."""
try:
return await self.hass.async_add_executor_job(self._update)
except steam.api.HTTPTimeoutError as ex:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from ex
except steam.api.HTTPError as ex:
_LOGGER.debug("Full exception:", exc_info=True)
if "401" in str(ex) or "403" in str(ex):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_exception",
) from ex
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="request_exception",
) from ex
if "401" in str(ex):
raise ConfigEntryAuthFailed from ex
raise UpdateFailed(ex) from ex

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