mirror of
https://github.com/home-assistant/core.git
synced 2026-07-01 02:55:57 +02:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bfa50dd1a | |||
| 24c68fe3b2 | |||
| 9424427a55 | |||
| 94c5700f22 | |||
| 6b7d2b34f9 | |||
| 939dd7abce | |||
| 1a330ca23e | |||
| 5a1cc024dd | |||
| 16a6824799 | |||
| 8cb101ae85 | |||
| 7950469e31 | |||
| 9b08858bf0 | |||
| b0355f8a9e | |||
| 1c1d95652a | |||
| d1275c1128 | |||
| 9c34477559 | |||
| 8fc2c24ea0 | |||
| 638f081a6d | |||
| d99e54a14b | |||
| a2e00eb0b5 | |||
| 0ea1ec6fc7 | |||
| de6b679e6e | |||
| 3bcdb621ec | |||
| 003aed3d44 | |||
| 1d0beeeef9 | |||
| f0850b67f2 | |||
| fbd83f8a52 | |||
| bc18d0678a | |||
| c0971e99e9 | |||
| 771da03707 | |||
| 80a04d2820 | |||
| feadcc057d | |||
| cf1e0f0aa5 | |||
| cfa49bb0dd | |||
| a0d96a2c62 | |||
| eb9ebd17b6 | |||
| 5ea38fcc07 | |||
| 15ef228cfa | |||
| e815c9f0cc | |||
| 434b3ca309 | |||
| a557e96c53 | |||
| 324c95140b |
@@ -11,12 +11,6 @@ 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/**
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,46 @@
|
||||
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}"
|
||||
@@ -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.05.0"
|
||||
BASE_IMAGE_VERSION: "2026.07.0"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
permissions: {}
|
||||
|
||||
+93
-285
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
CACHE_VERSION: 4
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.8"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
@@ -89,6 +89,8 @@ jobs:
|
||||
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
|
||||
postgresql_groups: ${{ steps.info.outputs.postgresql_groups }}
|
||||
python_versions: ${{ steps.info.outputs.python_versions }}
|
||||
default_python: ${{ steps.info.outputs.default_python }}
|
||||
uv_version: ${{ steps.info.outputs.uv_version }}
|
||||
test_full_suite: ${{ steps.info.outputs.test_full_suite }}
|
||||
test_group_count: ${{ steps.info.outputs.test_group_count }}
|
||||
test_groups: ${{ steps.info.outputs.test_groups }}
|
||||
@@ -235,6 +237,11 @@ jobs:
|
||||
echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT
|
||||
echo "python_versions: ${all_python_versions}"
|
||||
echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT
|
||||
echo "default_python: ${default_python}"
|
||||
echo "default_python=${default_python}" >> $GITHUB_OUTPUT
|
||||
uv_version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)
|
||||
echo "uv_version: ${uv_version}"
|
||||
echo "uv_version=${uv_version}" >> $GITHUB_OUTPUT
|
||||
echo "test_full_suite: ${test_full_suite}"
|
||||
echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT
|
||||
echo "integrations_glob: ${integrations_glob}"
|
||||
@@ -344,82 +351,18 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }} and build venv
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/restore-or-build-venv
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
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
|
||||
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"
|
||||
- name: Dump pip freeze
|
||||
run: |
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
uv pip freeze >> pip_freeze.txt
|
||||
@@ -434,26 +377,6 @@ 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
|
||||
@@ -478,21 +401,15 @@ jobs:
|
||||
with:
|
||||
packages: libturbojpeg
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Set up Python
|
||||
- name: Set up Python and restore venv
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/restore-or-build-venv
|
||||
with:
|
||||
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 }}
|
||||
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 }}
|
||||
- name: Run hassfest
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -515,21 +432,15 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
- name: Set up Python and restore venv
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/restore-or-build-venv
|
||||
with:
|
||||
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 }}
|
||||
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 }}
|
||||
- name: Run gen_requirements_all.py
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -553,13 +464,13 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Run gen_copilot_instructions.py
|
||||
run: |
|
||||
python -m script.gen_copilot_instructions validate
|
||||
uv run --no-project python -m script.gen_copilot_instructions validate
|
||||
|
||||
dependency-review:
|
||||
name: Dependency review
|
||||
@@ -606,21 +517,15 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }} and restore venv
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/restore-or-build-venv
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
|
||||
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
|
||||
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Extract license data
|
||||
env:
|
||||
PYTHON_VERSION: ${{ matrix.python-version }}
|
||||
@@ -657,21 +562,15 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
- name: Set up Python and restore venv
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/restore-or-build-venv
|
||||
with:
|
||||
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 }}
|
||||
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 }}
|
||||
- name: Register pylint problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pylint.json"
|
||||
@@ -710,21 +609,15 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
- name: Set up Python and restore venv
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/restore-or-build-venv
|
||||
with:
|
||||
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 }}
|
||||
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 }}
|
||||
- name: Register pylint problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pylint.json"
|
||||
@@ -761,29 +654,23 @@ 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: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
- name: Set up Python and restore venv
|
||||
id: python
|
||||
uses: ./.github/actions/restore-or-build-venv
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
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 }}
|
||||
- name: Restore mypy cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .mypy_cache
|
||||
key: >-
|
||||
@@ -812,55 +699,6 @@ 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
|
||||
@@ -887,21 +725,15 @@ jobs:
|
||||
libturbojpeg
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python
|
||||
- name: Set up Python and restore venv
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/restore-or-build-venv
|
||||
with:
|
||||
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 }}
|
||||
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 }}
|
||||
- name: Run split_tests.py
|
||||
env:
|
||||
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
|
||||
@@ -952,21 +784,15 @@ jobs:
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }} and restore venv
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/restore-or-build-venv
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
|
||||
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
|
||||
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/python.json"
|
||||
@@ -1094,21 +920,15 @@ jobs:
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }} and restore venv
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/restore-or-build-venv
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
|
||||
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
|
||||
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/python.json"
|
||||
@@ -1250,21 +1070,15 @@ jobs:
|
||||
with:
|
||||
packages: postgresql-server-dev-14
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }} and restore venv
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/restore-or-build-venv
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
|
||||
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
|
||||
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/python.json"
|
||||
@@ -1418,21 +1232,15 @@ jobs:
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }} and restore venv
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/restore-or-build-venv
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
|
||||
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
|
||||
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/python.json"
|
||||
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
uses: home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716 # 2026.07.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@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
uses: home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716 # 2026.07.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""CodSpeed performance benchmarks for Home Assistant core hot paths."""
|
||||
@@ -1,45 +0,0 @@
|
||||
"""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
|
||||
@@ -1,148 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,138 +0,0 @@
|
||||
"""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
|
||||
@@ -1,112 +0,0 @@
|
||||
"""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
|
||||
@@ -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
|
||||
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
|
||||
[
|
||||
DOMAIN,
|
||||
self.adguard.host,
|
||||
|
||||
@@ -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
|
||||
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
|
||||
[
|
||||
DOMAIN,
|
||||
self.adguard.host,
|
||||
|
||||
@@ -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
|
||||
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
|
||||
[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 CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||
from homeassistant.const import UnitOfDensity
|
||||
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 CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||
return UnitOfDensity.MICROGRAMS_PER_CUBIC_METER
|
||||
|
||||
@@ -5,13 +5,7 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, UnitOfDensity, UnitOfRatio
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import (
|
||||
@@ -60,12 +54,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)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
),
|
||||
"is_ozone_value": make_entity_numerical_condition_with_unit(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
),
|
||||
"is_voc_value": make_entity_numerical_condition_with_unit(
|
||||
@@ -74,7 +68,7 @@ CONDITIONS: dict[str, type[Condition]] = {
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
MassVolumeConcentrationConverter,
|
||||
),
|
||||
"is_voc_ratio_value": make_entity_numerical_condition_with_unit(
|
||||
@@ -83,48 +77,48 @@ CONDITIONS: dict[str, type[Condition]] = {
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitlessRatioConverter,
|
||||
),
|
||||
"is_no_value": make_entity_numerical_condition_with_unit(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
),
|
||||
"is_no2_value": make_entity_numerical_condition_with_unit(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
),
|
||||
"is_so2_value": make_entity_numerical_condition_with_unit(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.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=CONCENTRATION_PARTS_PER_MILLION,
|
||||
valid_unit=UnitOfRatio.PARTS_PER_MILLION,
|
||||
),
|
||||
"is_pm1_value": make_entity_numerical_condition(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"is_pm25_value": make_entity_numerical_condition(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"is_pm4_value": make_entity_numerical_condition(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"is_pm10_value": make_entity_numerical_condition(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"is_n2o_value": make_entity_numerical_condition(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,7 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, UnitOfDensity, UnitOfRatio
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
@@ -65,25 +59,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)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
),
|
||||
"co_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"ozone_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
),
|
||||
"ozone_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
)
|
||||
),
|
||||
@@ -93,7 +87,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
MassVolumeConcentrationConverter,
|
||||
),
|
||||
"voc_crossed_threshold": (
|
||||
@@ -103,7 +97,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
MassVolumeConcentrationConverter,
|
||||
)
|
||||
),
|
||||
@@ -113,7 +107,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitlessRatioConverter,
|
||||
),
|
||||
"voc_ratio_crossed_threshold": (
|
||||
@@ -123,13 +117,13 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitlessRatioConverter,
|
||||
)
|
||||
),
|
||||
"no_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
),
|
||||
"no_crossed_threshold": (
|
||||
@@ -139,13 +133,13 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
device_class=SensorDeviceClass.NITROGEN_MONOXIDE
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"no2_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
),
|
||||
"no2_crossed_threshold": (
|
||||
@@ -155,70 +149,70 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
device_class=SensorDeviceClass.NITROGEN_DIOXIDE
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"so2_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.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)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.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=CONCENTRATION_PARTS_PER_MILLION,
|
||||
valid_unit=UnitOfRatio.PARTS_PER_MILLION,
|
||||
),
|
||||
"co2_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
|
||||
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
|
||||
valid_unit=UnitOfRatio.PARTS_PER_MILLION,
|
||||
),
|
||||
"pm1_changed": make_entity_numerical_state_changed_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm1_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm25_changed": make_entity_numerical_state_changed_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm25_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm4_changed": make_entity_numerical_state_changed_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm4_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm10_changed": make_entity_numerical_state_changed_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm10_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"n2o_changed": make_entity_numerical_state_changed_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"n2o_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
valid_unit=UnitOfDensity.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"
|
||||
self._attr_unique_id = f"{coordinator.serial_number}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@cached_property
|
||||
@override
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{status.data.derived.mac}_firmware_update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@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"
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||
name=coordinator.config_entry.data[CONF_HOST],
|
||||
|
||||
@@ -28,7 +28,6 @@ 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,
|
||||
@@ -66,7 +65,7 @@ from .const import (
|
||||
TOOL_SEARCH_UNSUPPORTED_MODELS,
|
||||
PromptCaching,
|
||||
)
|
||||
from .coordinator import model_alias
|
||||
from .coordinator import async_create_client, model_alias
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AnthropicConfigEntry
|
||||
@@ -95,9 +94,7 @@ 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 = anthropic.AsyncAnthropic(
|
||||
api_key=data[CONF_API_KEY], http_client=get_async_client(hass)
|
||||
)
|
||||
client = await async_create_client(hass, data[CONF_API_KEY])
|
||||
await client.models.list(timeout=10.0)
|
||||
|
||||
|
||||
@@ -549,9 +546,8 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
location_data: dict[str, str] = {}
|
||||
zone_home = self.hass.states.get(ENTITY_ID_HOME)
|
||||
if zone_home is not None:
|
||||
client = anthropic.AsyncAnthropic(
|
||||
api_key=self._get_entry().data[CONF_API_KEY],
|
||||
http_client=get_async_client(self.hass),
|
||||
client = await async_create_client(
|
||||
self.hass, self._get_entry().data[CONF_API_KEY]
|
||||
)
|
||||
location_schema = vol.Schema(
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Coordinator for the Anthropic integration."""
|
||||
|
||||
import datetime
|
||||
from functools import partial
|
||||
from typing import override
|
||||
|
||||
import anthropic
|
||||
@@ -20,6 +21,19 @@ 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."""
|
||||
@@ -33,7 +47,8 @@ def model_alias(model_id: str) -> str:
|
||||
class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]):
|
||||
"""Coordinator using different intervals after success and failure."""
|
||||
|
||||
client: anthropic.AsyncAnthropic
|
||||
config_entry: AnthropicConfigEntry
|
||||
_client: anthropic.AsyncAnthropic
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: AnthropicConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
@@ -46,8 +61,17 @@ class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]
|
||||
update_method=self.async_update_data,
|
||||
always_update=False,
|
||||
)
|
||||
self.client = anthropic.AsyncAnthropic(
|
||||
api_key=config_entry.data[CONF_API_KEY], http_client=get_async_client(hass)
|
||||
|
||||
@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]
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -27,7 +27,6 @@ from homeassistant.const import ( # noqa: F401
|
||||
CONF_PATH,
|
||||
CONF_TRIGGERS,
|
||||
CONF_VARIABLES,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
SERVICE_RELOAD,
|
||||
SERVICE_TOGGLE,
|
||||
SERVICE_TURN_OFF,
|
||||
@@ -38,7 +37,7 @@ from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Context,
|
||||
CoreState,
|
||||
Event,
|
||||
HassJob,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
callback,
|
||||
@@ -830,13 +829,13 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
if self._condition is not None:
|
||||
self._condition.async_unload()
|
||||
|
||||
async def _async_enable_automation(self, event: Event) -> None:
|
||||
"""Start automation on startup."""
|
||||
async def _async_enable_automation(self) -> None:
|
||||
"""Arm the automation's triggers 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(True)
|
||||
self._async_detach_triggers = await self._async_attach_triggers()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_enable(self) -> None:
|
||||
@@ -851,13 +850,14 @@ 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(False)
|
||||
self._async_detach_triggers = await self._async_attach_triggers()
|
||||
return
|
||||
|
||||
self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
self._async_enable_automation,
|
||||
)
|
||||
# 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))
|
||||
|
||||
async def _async_disable(self, stop_actions: bool = DEFAULT_STOP_ACTIONS) -> None:
|
||||
"""Disable the automation entity.
|
||||
@@ -942,9 +942,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
|
||||
script_execution_set("not_triggered")
|
||||
|
||||
async def _async_attach_triggers(
|
||||
self, home_assistant_start: bool
|
||||
) -> Callable[[], None] | None:
|
||||
async def _async_attach_triggers(self) -> Callable[[], None] | None:
|
||||
"""Set up the triggers."""
|
||||
this = None
|
||||
if state := self.hass.states.get(self.entity_id):
|
||||
@@ -968,8 +966,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
DOMAIN,
|
||||
str(self.name),
|
||||
self._log_callback,
|
||||
home_assistant_start,
|
||||
variables,
|
||||
variables=variables,
|
||||
did_not_trigger=self._handle_not_triggered,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
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_translation_key = "automatic_backup_event"
|
||||
|
||||
@callback
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{camera.serial}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, camera.serial)},
|
||||
serial_number=camera.serial,
|
||||
|
||||
@@ -187,7 +187,7 @@ async def async_process_advertisements(
|
||||
)
|
||||
stack.callback(unload)
|
||||
|
||||
if mode == BluetoothScanningMode.ACTIVE:
|
||||
if mode is 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"],
|
||||
"after_dependencies": ["esphome", "shelly", "ruuvi_gateway", "smlight"],
|
||||
"codeowners": ["@bdraco"],
|
||||
"dependencies": ["bluetooth"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bluetooth_adapters",
|
||||
|
||||
@@ -36,7 +36,7 @@ class BroadlinkTime(BroadlinkEntity, TimeEntity):
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(device)
|
||||
|
||||
self._attr_unique_id = f"{device.unique_id}-device_time"
|
||||
self._attr_unique_id = f"{device.unique_id}-device_time" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@override
|
||||
def _update_state(self, data: dict[str, Any]) -> None:
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{mac}-climate" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
else:
|
||||
self._attr_unique_id = f"{mac}-climate-{circuit}"
|
||||
self._attr_unique_id = f"{mac}-climate-{circuit}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
# Set temperature range from per-circuit static data
|
||||
if (static := data.static.get(circuit)) is not None:
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ccm15",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["py_ccm15==0.6.0"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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,6 +12,10 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""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: todo
|
||||
diagnostics: done
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This is a cloud polling integration with no local discovery mechanism.
|
||||
|
||||
@@ -8,6 +8,7 @@ from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA,
|
||||
EntityTriggerBase,
|
||||
NotTriggeredReasonReporter,
|
||||
Trigger,
|
||||
)
|
||||
|
||||
@@ -30,7 +31,11 @@ class CounterBaseIntegerTrigger(EntityTriggerBase):
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
@override
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
"""Check if the new state is valid."""
|
||||
return _is_integer_state(state)
|
||||
|
||||
@@ -63,7 +68,11 @@ class CounterMaxReachedTrigger(CounterValueBaseTrigger):
|
||||
"""Trigger for when a counter reaches its maximum value."""
|
||||
|
||||
@override
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
"""Check if the new state matches the expected state(s)."""
|
||||
if (max_value := state.attributes.get(CONF_MAXIMUM)) is None:
|
||||
return False
|
||||
@@ -74,7 +83,11 @@ class CounterMinReachedTrigger(CounterValueBaseTrigger):
|
||||
"""Trigger for when a counter reaches its minimum value."""
|
||||
|
||||
@override
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
"""Check if the new state matches the expected state(s)."""
|
||||
if (min_value := state.attributes.get(CONF_MINIMUM)) is None:
|
||||
return False
|
||||
@@ -85,7 +98,11 @@ class CounterResetTrigger(CounterValueBaseTrigger):
|
||||
"""Trigger for reset of counter entities."""
|
||||
|
||||
@override
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
"""Check if the new state matches the expected state(s)."""
|
||||
if (init_state := state.attributes.get(CONF_INITIAL)) is None:
|
||||
return False
|
||||
|
||||
@@ -5,7 +5,11 @@ from typing import override
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityTriggerBase,
|
||||
NotTriggeredReasonReporter,
|
||||
Trigger,
|
||||
)
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
from .models import CoverDomainSpec
|
||||
@@ -24,7 +28,11 @@ class CoverTriggerBase(EntityTriggerBase):
|
||||
return state.state
|
||||
|
||||
@override
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> 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
|
||||
|
||||
@@ -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
|
||||
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._condition = condition
|
||||
self._native_temperature = temperature
|
||||
self._native_temperature_unit = temperature_unit
|
||||
|
||||
@@ -10,7 +10,11 @@ from homeassistant.components.event import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
|
||||
from homeassistant.helpers.trigger import (
|
||||
NotTriggeredReasonReporter,
|
||||
StatelessEntityTriggerBase,
|
||||
Trigger,
|
||||
)
|
||||
|
||||
|
||||
class DoorbellRangTrigger(StatelessEntityTriggerBase):
|
||||
@@ -19,7 +23,11 @@ class DoorbellRangTrigger(StatelessEntityTriggerBase):
|
||||
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
|
||||
|
||||
@override
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
"""Check if the event type is ring."""
|
||||
return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
|
||||
|
||||
|
||||
@@ -83,9 +83,7 @@ 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).replace(
|
||||
second=0, microsecond=0
|
||||
)
|
||||
dt_util.utc_from_timestamp(node.ventilation.time_state_end)
|
||||
if node.ventilation and node.ventilation.time_state_end != 0
|
||||
else None
|
||||
),
|
||||
|
||||
@@ -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}"
|
||||
f"{self.thermostat['identifier']}_notify_{thermostat_index}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
)
|
||||
|
||||
@override
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aioecowitt==2025.9.2"]
|
||||
"requirements": ["aioecowitt==2026.6.0"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==3.0.0"],
|
||||
"requirements": ["pyenphase==3.0.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -10,6 +10,7 @@ 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,
|
||||
@@ -42,7 +43,11 @@ class EventReceivedTrigger(StatelessEntityTriggerBase):
|
||||
self._event_types = set(self._options[CONF_EVENT_TYPE])
|
||||
|
||||
@override
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
"""Check if the event type matches one of the configured types."""
|
||||
return state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
|
||||
|
||||
|
||||
@@ -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}"
|
||||
f"{slugify(controller.hub_serial)}.scene.{fibaro_scene.fibaro_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
)
|
||||
self._attr_extra_state_attributes = {"fibaro_id": fibaro_scene.fibaro_id}
|
||||
# propagate hidden attribute set in fibaro home center to HA
|
||||
|
||||
@@ -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
|
||||
from fritzconnection.core.exceptions import FritzActionError, FritzConnectionException
|
||||
from fritzconnection.lib.fritzcall import FritzCall
|
||||
from fritzconnection.lib.fritzhosts import FritzHosts
|
||||
from fritzconnection.lib.fritzstatus import FritzStatus
|
||||
@@ -267,9 +267,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
) = self._update_device_info()
|
||||
|
||||
if self.fritz_status.has_wan_support:
|
||||
self.device_conn_type = (
|
||||
self.fritz_status.get_default_connection_service().connection_service
|
||||
)
|
||||
self.device_conn_type = self.fritz_status.connection_service
|
||||
self.device_is_router = self.fritz_status.has_wan_enabled
|
||||
|
||||
self.has_call_deflections = "X_AVM-DE_OnTel1" in self.connection.services
|
||||
@@ -682,7 +680,18 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
|
||||
async def async_trigger_reconnect(self) -> None:
|
||||
"""Trigger device reconnect."""
|
||||
await self.hass.async_add_executor_job(self.connection.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
|
||||
|
||||
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.1"]
|
||||
"requirements": ["home-assistant-frontend==20260624.2"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{coordinator.data['deviceID']}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@override
|
||||
async def async_camera_image(
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_EVENT, CONF_PLATFORM
|
||||
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
|
||||
from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -45,9 +45,12 @@ async def async_attach_trigger(
|
||||
},
|
||||
)
|
||||
|
||||
# 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"]:
|
||||
unsub: CALLBACK_TYPE | None = None
|
||||
|
||||
@callback
|
||||
def hass_started(_: Event) -> None:
|
||||
nonlocal unsub
|
||||
unsub = None
|
||||
hass.async_run_hass_job(
|
||||
job,
|
||||
{
|
||||
@@ -60,4 +63,13 @@ async def async_attach_trigger(
|
||||
},
|
||||
)
|
||||
|
||||
return lambda: None
|
||||
# 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
|
||||
|
||||
@@ -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 == AddonState.NOT_INSTALLED:
|
||||
if info is not None and info.state is 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.SENSOR]
|
||||
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HypontechConfigEntry) -> bool:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""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"
|
||||
@@ -36,7 +36,7 @@ class ImmichUpdateEntity(ImmichEntity, UpdateEntity):
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_update"
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@property
|
||||
@override
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Virtual integration: IoTorero."""
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "iotorero",
|
||||
"name": "IoTorero",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "esphome"
|
||||
}
|
||||
@@ -18,13 +18,10 @@ 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,
|
||||
@@ -40,6 +37,7 @@ from homeassistant.const import (
|
||||
UV_INDEX,
|
||||
Platform,
|
||||
UnitOfApparentPower,
|
||||
UnitOfDensity,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -49,6 +47,7 @@ from homeassistant.const import (
|
||||
UnitOfMass,
|
||||
UnitOfPower,
|
||||
UnitOfPressure,
|
||||
UnitOfRatio,
|
||||
UnitOfReactivePower,
|
||||
UnitOfSoundPressure,
|
||||
UnitOfSpeed,
|
||||
@@ -341,8 +340,8 @@ UOM_FRIENDLY_NAME = {
|
||||
"18": UnitOfLength.FEET,
|
||||
"19": UnitOfTime.HOURS,
|
||||
"20": UnitOfTime.HOURS,
|
||||
"21": PERCENTAGE,
|
||||
"22": PERCENTAGE,
|
||||
"21": UnitOfRatio.PERCENTAGE,
|
||||
"22": UnitOfRatio.PERCENTAGE,
|
||||
"23": UnitOfPressure.INHG,
|
||||
"24": UnitOfVolumetricFlux.INCHES_PER_HOUR,
|
||||
UOM_INDEX: UOM_INDEX, # Index type. Use "node.formatted" for value
|
||||
@@ -371,10 +370,10 @@ UOM_FRIENDLY_NAME = {
|
||||
"48": UnitOfSpeed.MILES_PER_HOUR,
|
||||
"49": UnitOfSpeed.METERS_PER_SECOND,
|
||||
"50": "Ω",
|
||||
UOM_PERCENTAGE: PERCENTAGE,
|
||||
UOM_PERCENTAGE: UnitOfRatio.PERCENTAGE,
|
||||
"52": UnitOfMass.POUNDS,
|
||||
"53": "pf",
|
||||
"54": CONCENTRATION_PARTS_PER_MILLION,
|
||||
"54": UnitOfRatio.PARTS_PER_MILLION,
|
||||
"55": "pulse count",
|
||||
"57": UnitOfTime.SECONDS,
|
||||
"58": UnitOfTime.SECONDS,
|
||||
@@ -423,7 +422,7 @@ UOM_FRIENDLY_NAME = {
|
||||
"118": UnitOfPressure.HPA,
|
||||
"119": UnitOfEnergy.WATT_HOUR,
|
||||
"120": UnitOfVolumetricFlux.INCHES_PER_DAY,
|
||||
"122": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # Microgram per cubic meter
|
||||
"122": UnitOfDensity.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"
|
||||
return f"{self._device_id}_air_quality" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@property
|
||||
@override
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.const import Platform, UnitOfDensity, UnitOfRatio
|
||||
|
||||
DOMAIN = "kaiterra"
|
||||
|
||||
@@ -55,13 +48,13 @@ ATTR_AQI_POLLUTANT = "air_quality_index_pollutant"
|
||||
AVAILABLE_AQI_STANDARDS = ["us", "cn", "in"]
|
||||
AVAILABLE_UNITS = [
|
||||
"x",
|
||||
PERCENTAGE,
|
||||
UnitOfRatio.PERCENTAGE,
|
||||
"C",
|
||||
"F",
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfRatio.PARTS_PER_MILLION,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
]
|
||||
AVAILABLE_DEVICE_TYPES = ["laseregg", "sensedge"]
|
||||
|
||||
|
||||
@@ -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 != ConfigEntryState.LOADED:
|
||||
if entry.state is not 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()}"
|
||||
self._attr_unique_id = f"test-weather-{name.lower()}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
self._condition = condition
|
||||
self._native_temperature = temperature
|
||||
self._native_temperature_unit = temperature_unit
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{coordinator.data.serial_number}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@property
|
||||
@override
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{coordinator.device_id}_presentation_light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@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}"
|
||||
self._attr_unique_id = f"scene_{bridge_unique_id}_{self._scene_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@override
|
||||
async def async_activate(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -24,6 +24,8 @@ 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,12 +48,14 @@ 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,
|
||||
@@ -126,6 +128,8 @@ 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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -313,6 +317,8 @@ 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(
|
||||
@@ -334,6 +340,8 @@ 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,6 +278,14 @@ 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,6 +219,10 @@
|
||||
"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"
|
||||
@@ -239,6 +243,10 @@
|
||||
"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,6 +9,7 @@ from homeassistant.helpers.trigger import (
|
||||
EntityNumericalStateCrossedThresholdTriggerBase,
|
||||
EntityNumericalStateTriggerBase,
|
||||
EntityTriggerBase,
|
||||
NotTriggeredReasonReporter,
|
||||
Trigger,
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
@@ -60,7 +61,11 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
|
||||
return self.is_muted(from_state) != self.is_muted(to_state)
|
||||
|
||||
@override
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> 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=cmd, **params))
|
||||
return list(self.api(cmd=cmd))
|
||||
return list(self.api(cmd, **params))
|
||||
return list(self.api(cmd))
|
||||
except (
|
||||
librouteros.exceptions.ConnectionClosed,
|
||||
OSError,
|
||||
@@ -318,8 +318,7 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api:
|
||||
"""Connect to Mikrotik hub."""
|
||||
_LOGGER.debug("Connecting to Mikrotik hub [%s]", entry[CONF_HOST])
|
||||
|
||||
_login_method = (login_plain, login_token)
|
||||
kwargs = {"login_methods": _login_method, "port": entry["port"], "encoding": "utf8"}
|
||||
kwargs = {"port": entry["port"], "encoding": "utf8"}
|
||||
|
||||
if entry[CONF_VERIFY_SSL]:
|
||||
ssl_context = ssl.create_default_context()
|
||||
@@ -328,22 +327,30 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api:
|
||||
_ssl_wrapper = ssl_context.wrap_socket
|
||||
kwargs["ssl_wrapper"] = _ssl_wrapper
|
||||
|
||||
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
|
||||
_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
|
||||
|
||||
_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==3.2.1"]
|
||||
"requirements": ["librouteros==4.1.1"]
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ from .const import (
|
||||
DEFAULT_QOS,
|
||||
DEFAULT_TRANSPORT,
|
||||
DEFAULT_WILL,
|
||||
DEFAULT_WS_HEADERS,
|
||||
DEFAULT_WS_PATH,
|
||||
DOMAIN,
|
||||
MQTT_CONNECTION_STATE,
|
||||
@@ -414,7 +413,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, DEFAULT_WS_HEADERS)
|
||||
ws_headers: dict[str, str] = config.get(CONF_WS_HEADERS, {})
|
||||
self._client.ws_set_options(ws_path, ws_headers)
|
||||
if certificate is not None:
|
||||
self._client.tls_set(
|
||||
|
||||
@@ -373,7 +373,6 @@ 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,
|
||||
@@ -414,7 +413,6 @@ from .const import (
|
||||
DEFAULT_TILT_OPEN_POSITION,
|
||||
DEFAULT_TRANSPORT,
|
||||
DEFAULT_WILL,
|
||||
DEFAULT_WS_PATH,
|
||||
DOMAIN,
|
||||
REMOTE_CODE,
|
||||
REMOTE_CODE_TEXT,
|
||||
@@ -441,7 +439,7 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 5
|
||||
|
||||
CONF_CLIENT_KEY_PASSWORD = "client_key_password"
|
||||
|
||||
ADVANCED_OPTIONS = "advanced_options"
|
||||
OTHER_SETTINGS = "other_settings"
|
||||
SET_CA_CERT = "set_ca_cert"
|
||||
SET_CLIENT_CERT = "set_client_cert"
|
||||
|
||||
@@ -1124,7 +1122,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
|
||||
|
||||
|
||||
@@ -1506,7 +1504,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,
|
||||
@@ -1678,13 +1676,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: {
|
||||
@@ -3125,7 +3123,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,
|
||||
@@ -3133,7 +3131,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,
|
||||
@@ -3141,7 +3139,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,
|
||||
@@ -3149,21 +3147,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: {
|
||||
@@ -3372,7 +3370,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: {
|
||||
@@ -3798,10 +3796,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),
|
||||
@@ -4036,24 +4034,22 @@ def subentry_schema_default_data_from_fields(
|
||||
@callback
|
||||
def update_password_from_user_input(
|
||||
entry_password: str | None, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
) -> None:
|
||||
"""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 = substituted_used_data.pop(CONF_PASSWORD, None)
|
||||
user_password: str | None = user_input.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:
|
||||
substituted_used_data[CONF_PASSWORD] = password
|
||||
return substituted_used_data
|
||||
user_input[CONF_PASSWORD] = password
|
||||
|
||||
|
||||
REAUTH_SCHEMA = vol.Schema(
|
||||
@@ -4063,6 +4059,35 @@ 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."""
|
||||
@@ -4072,24 +4097,26 @@ 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:
|
||||
@@ -4310,8 +4337,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
if user_input:
|
||||
substituted_used_data = update_password_from_user_input(
|
||||
reauth_entry.data.get(CONF_PASSWORD), user_input
|
||||
substituted_used_data = deepcopy(user_input)
|
||||
update_password_from_user_input(
|
||||
reauth_entry.data.get(CONF_PASSWORD), substituted_used_data
|
||||
)
|
||||
new_entry_data = {**reauth_entry.data, **substituted_used_data}
|
||||
if await self.hass.async_add_executor_job(
|
||||
@@ -4335,49 +4363,76 @@ 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] = {}
|
||||
fields: OrderedDict[Any, Any] = OrderedDict()
|
||||
validated_user_input: dict[str, Any] = {}
|
||||
schema = CONFIG_DATAFLOW_SCHEMA
|
||||
entry_config_update: dict[str, Any] = {}
|
||||
entry_defaults: dict[str, Any] | None = None
|
||||
if is_reconfigure := (self.source == SOURCE_RECONFIGURE):
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
if await async_get_broker_settings(
|
||||
entry_defaults = self.async_get_entry_defaults()
|
||||
if await async_validate_broker_settings(
|
||||
self,
|
||||
fields,
|
||||
reconfigure_entry.data if is_reconfigure else None,
|
||||
user_input,
|
||||
validated_user_input,
|
||||
entry_config_update,
|
||||
errors,
|
||||
):
|
||||
if is_reconfigure:
|
||||
validated_user_input = update_password_from_user_input(
|
||||
reconfigure_entry.data.get(CONF_PASSWORD), validated_user_input
|
||||
return self.async_update_and_abort(
|
||||
reconfigure_entry,
|
||||
data=entry_config_update,
|
||||
)
|
||||
|
||||
can_connect = await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
validated_user_input,
|
||||
return self.async_create_entry(
|
||||
title=entry_config_update[CONF_BROKER],
|
||||
data=entry_config_update,
|
||||
)
|
||||
|
||||
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
|
||||
schema = self.add_suggested_values_to_schema(
|
||||
schema, (entry_defaults or {}) | (user_input or {})
|
||||
)
|
||||
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
|
||||
@@ -4688,8 +4743,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:
|
||||
@@ -5250,331 +5305,162 @@ async def _get_uploaded_file(hass: HomeAssistant, id: str) -> bytes:
|
||||
return await hass.async_add_executor_job(_proces_uploaded_file)
|
||||
|
||||
|
||||
def _validate_pki_file(
|
||||
file_id: str | None, pem_data: str | None, errors: dict[str, str], error: str
|
||||
) -> bool:
|
||||
"""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
|
||||
|
||||
|
||||
async def async_get_broker_settings(
|
||||
flow: ConfigFlow | OptionsFlow,
|
||||
fields: OrderedDict[Any, Any],
|
||||
async def async_validate_broker_settings(
|
||||
flow: FlowHandler,
|
||||
entry_config: MappingProxyType[str, Any] | None,
|
||||
user_input: dict[str, Any] | None,
|
||||
validated_user_input: dict[str, Any],
|
||||
entry_config_update: dict[str, Any],
|
||||
errors: dict[str, str],
|
||||
) -> bool:
|
||||
"""Build the config flow schema to collect the broker settings.
|
||||
"""Validate the broker settings, and return the updated entry dataset."""
|
||||
|
||||
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],
|
||||
async def _async_process_file_upload(
|
||||
upload_id: str,
|
||||
field: str,
|
||||
pem_type: PEMType,
|
||||
error_code: str,
|
||||
password: str | None = None,
|
||||
) -> 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
|
||||
)
|
||||
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
|
||||
"""Get uploaded file, or a preserved copy, and convert to a PEM file."""
|
||||
try:
|
||||
validated_user_input[CONF_WS_HEADERS] = json_loads(
|
||||
validated_user_input.get(CONF_WS_HEADERS, "{}")
|
||||
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,
|
||||
):
|
||||
errors["base"] = error
|
||||
return False
|
||||
|
||||
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, "{}")
|
||||
)
|
||||
schema = vol.Schema({cv.string: cv.template})
|
||||
schema(validated_user_input[CONF_WS_HEADERS])
|
||||
schema = vol.Schema({str: str})
|
||||
schema(entry_config_update[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
|
||||
|
||||
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
|
||||
errors["base"] = "cannot_connect"
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -315,7 +315,6 @@ 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
|
||||
|
||||
@@ -26,46 +26,53 @@
|
||||
"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",
|
||||
"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"
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"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.",
|
||||
"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."
|
||||
"username": "The username to log in to your MQTT broker."
|
||||
},
|
||||
"description": "Please enter the connection information of 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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hassio_confirm": {
|
||||
"description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the {addon} app?",
|
||||
@@ -1178,48 +1185,6 @@
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
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_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._temp_sensor.webio_serial)}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._output.webio_serial)},
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{self._device.name}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@override
|
||||
async def async_added_to_hass(self) -> None:
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{self.device.entity_id}-light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
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"
|
||||
self._attr_unique_id = f"{self.device.entity_id}-light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
if self.device.brightness is not None:
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
|
||||
@@ -69,7 +69,7 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity):
|
||||
configuration_url=CONF_URL_ENERGY,
|
||||
)
|
||||
|
||||
self._attr_unique_id = f"{self.home.entity_id}-schedule-select"
|
||||
self._attr_unique_id = f"{self.home.entity_id}-schedule-select" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
schedule = self.home.get_selected_schedule()
|
||||
assert schedule
|
||||
|
||||
@@ -43,7 +43,7 @@ class NetgearUpdateEntity(
|
||||
) -> None:
|
||||
"""Initialize a Netgear device."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.router.serial_number}-update"
|
||||
self._attr_unique_id = f"{coordinator.router.serial_number}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@property
|
||||
@override
|
||||
|
||||
@@ -6,15 +6,8 @@ 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,
|
||||
@@ -22,6 +15,7 @@ from homeassistant.const import (
|
||||
UnitOfBloodGlucoseConcentration,
|
||||
UnitOfConductivity,
|
||||
UnitOfDataRate,
|
||||
UnitOfDensity,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -34,6 +28,7 @@ from homeassistant.const import (
|
||||
UnitOfPower,
|
||||
UnitOfPrecipitationDepth,
|
||||
UnitOfPressure,
|
||||
UnitOfRatio,
|
||||
UnitOfReactiveEnergy,
|
||||
UnitOfReactivePower,
|
||||
UnitOfSoundPressure,
|
||||
@@ -516,22 +511,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: {
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.GRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower),
|
||||
NumberDeviceClass.AQI: {None},
|
||||
NumberDeviceClass.AREA: set(UnitOfArea),
|
||||
NumberDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure),
|
||||
NumberDeviceClass.BATTERY: {PERCENTAGE},
|
||||
NumberDeviceClass.BATTERY: {UnitOfRatio.PERCENTAGE},
|
||||
NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
|
||||
NumberDeviceClass.CO: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitOfRatio.PARTS_PER_MILLION,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
NumberDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION},
|
||||
NumberDeviceClass.CO2: {UnitOfRatio.PARTS_PER_MILLION},
|
||||
NumberDeviceClass.CONDUCTIVITY: set(UnitOfConductivity),
|
||||
NumberDeviceClass.CURRENT: set(UnitOfElectricCurrent),
|
||||
NumberDeviceClass.DATA_RATE: set(UnitOfDataRate),
|
||||
@@ -556,31 +551,31 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
UnitOfVolume.LITERS,
|
||||
UnitOfVolume.MILLE_CUBIC_FEET,
|
||||
},
|
||||
NumberDeviceClass.HUMIDITY: {PERCENTAGE},
|
||||
NumberDeviceClass.HUMIDITY: {UnitOfRatio.PERCENTAGE},
|
||||
NumberDeviceClass.ILLUMINANCE: {LIGHT_LUX},
|
||||
NumberDeviceClass.IRRADIANCE: set(UnitOfIrradiance),
|
||||
NumberDeviceClass.MOISTURE: {PERCENTAGE},
|
||||
NumberDeviceClass.MOISTURE: {UnitOfRatio.PERCENTAGE},
|
||||
NumberDeviceClass.NITROGEN_DIOXIDE: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitOfRatio.PARTS_PER_MILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
NumberDeviceClass.NITROGEN_MONOXIDE: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
NumberDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
|
||||
NumberDeviceClass.NITROUS_OXIDE: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
|
||||
NumberDeviceClass.OZONE: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitOfRatio.PARTS_PER_MILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
NumberDeviceClass.PH: {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.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.POWER: {
|
||||
UnitOfPower.MILLIWATT,
|
||||
UnitOfPower.WATT,
|
||||
@@ -601,18 +596,18 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
NumberDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure),
|
||||
NumberDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux},
|
||||
NumberDeviceClass.SULPHUR_DIOXIDE: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
NumberDeviceClass.TEMPERATURE: set(UnitOfTemperature),
|
||||
NumberDeviceClass.TEMPERATURE_DELTA: set(UnitOfTemperature),
|
||||
NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitOfRatio.PARTS_PER_MILLION,
|
||||
},
|
||||
NumberDeviceClass.VOLTAGE: set(UnitOfElectricPotential),
|
||||
NumberDeviceClass.VOLUME: set(UnitOfVolume),
|
||||
@@ -681,8 +676,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³": CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
|
||||
"\u00b5g/m³": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
"\u00b5g/ft³": UnitOfDensity.MICROGRAMS_PER_CUBIC_FOOT,
|
||||
"\u00b5g/m³": UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
"\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light
|
||||
"\u00b5g": UnitOfMass.MICROGRAMS,
|
||||
"\u00b5s": UnitOfTime.MICROSECONDS,
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{device.uuid()}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
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 != ConfigEntryState.LOADED:
|
||||
if self._get_entry().state is not ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
|
||||
if user_input is not None:
|
||||
|
||||
@@ -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}"
|
||||
self._attr_unique_id = f"sensor-{plex_server.machine_identifier}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
self._server = plex_server
|
||||
self.async_refresh_sensor = Debouncer(
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{device_id}-climate" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
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 != RequestStatus.SUCCESS:
|
||||
if client_status is not 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 != RequestStatus.SUCCESS:
|
||||
if status is not 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 CONCENTRATION_MICROGRAMS_PER_CUBIC_METER "μg/m³"
|
||||
# Unit conversion for UnitOfDensity.MICROGRAMS_PER_CUBIC_METER "μg/m³"
|
||||
# "μ" == "\u03bc" but the API uses "\u00b5"
|
||||
default = default.replace("\u03bc", "\u00b5")
|
||||
default = default.lower()
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{coordinator.base_station[KEY_ID]}-calendar" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
self._previous_event: dict[str, Any] | None = None
|
||||
|
||||
@property
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@property
|
||||
@override
|
||||
|
||||
@@ -5,11 +5,10 @@ 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,
|
||||
)
|
||||
@@ -53,6 +52,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: CONCENTRATION_PARTS_PER_MILLION,
|
||||
UNIT.PERCENT: PERCENTAGE,
|
||||
UNIT.PARTS_PER_MILLION: UnitOfRatio.PARTS_PER_MILLION,
|
||||
UNIT.PERCENT: UnitOfRatio.PERCENTAGE,
|
||||
}
|
||||
|
||||
@@ -6,15 +6,8 @@ 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,
|
||||
@@ -22,6 +15,7 @@ from homeassistant.const import (
|
||||
UnitOfBloodGlucoseConcentration,
|
||||
UnitOfConductivity,
|
||||
UnitOfDataRate,
|
||||
UnitOfDensity,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -34,6 +28,7 @@ from homeassistant.const import (
|
||||
UnitOfPower,
|
||||
UnitOfPrecipitationDepth,
|
||||
UnitOfPressure,
|
||||
UnitOfRatio,
|
||||
UnitOfReactiveEnergy,
|
||||
UnitOfReactivePower,
|
||||
UnitOfSoundPressure,
|
||||
@@ -635,22 +630,22 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
|
||||
|
||||
DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
SensorDeviceClass.ABSOLUTE_HUMIDITY: {
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.GRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
SensorDeviceClass.APPARENT_POWER: set(UnitOfApparentPower),
|
||||
SensorDeviceClass.AQI: {None},
|
||||
SensorDeviceClass.AREA: set(UnitOfArea),
|
||||
SensorDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure),
|
||||
SensorDeviceClass.BATTERY: {PERCENTAGE},
|
||||
SensorDeviceClass.BATTERY: {UnitOfRatio.PERCENTAGE},
|
||||
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
|
||||
SensorDeviceClass.CO: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitOfRatio.PARTS_PER_MILLION,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
SensorDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION},
|
||||
SensorDeviceClass.CO2: {UnitOfRatio.PARTS_PER_MILLION},
|
||||
SensorDeviceClass.CONDUCTIVITY: set(UnitOfConductivity),
|
||||
SensorDeviceClass.CURRENT: set(UnitOfElectricCurrent),
|
||||
SensorDeviceClass.DATA_RATE: set(UnitOfDataRate),
|
||||
@@ -675,31 +670,31 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
UnitOfVolume.LITERS,
|
||||
UnitOfVolume.MILLE_CUBIC_FEET,
|
||||
},
|
||||
SensorDeviceClass.HUMIDITY: {PERCENTAGE},
|
||||
SensorDeviceClass.HUMIDITY: {UnitOfRatio.PERCENTAGE},
|
||||
SensorDeviceClass.ILLUMINANCE: {LIGHT_LUX},
|
||||
SensorDeviceClass.IRRADIANCE: set(UnitOfIrradiance),
|
||||
SensorDeviceClass.MOISTURE: {PERCENTAGE},
|
||||
SensorDeviceClass.MOISTURE: {UnitOfRatio.PERCENTAGE},
|
||||
SensorDeviceClass.NITROGEN_DIOXIDE: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitOfRatio.PARTS_PER_MILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
SensorDeviceClass.NITROGEN_MONOXIDE: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
SensorDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
|
||||
SensorDeviceClass.NITROUS_OXIDE: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
|
||||
SensorDeviceClass.OZONE: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitOfRatio.PARTS_PER_MILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
SensorDeviceClass.PH: {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.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.POWER: {
|
||||
UnitOfPower.MILLIWATT,
|
||||
UnitOfPower.WATT,
|
||||
@@ -720,18 +715,18 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
SensorDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure),
|
||||
SensorDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux},
|
||||
SensorDeviceClass.SULPHUR_DIOXIDE: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
SensorDeviceClass.TEMPERATURE: set(UnitOfTemperature),
|
||||
SensorDeviceClass.TEMPERATURE_DELTA: set(UnitOfTemperature),
|
||||
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
UnitOfRatio.PARTS_PER_BILLION,
|
||||
UnitOfRatio.PARTS_PER_MILLION,
|
||||
},
|
||||
SensorDeviceClass.VOLTAGE: set(UnitOfElectricPotential),
|
||||
SensorDeviceClass.VOLUME: set(UnitOfVolume),
|
||||
@@ -758,7 +753,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: (CONCENTRATION_GRAMS_PER_CUBIC_METER, 1),
|
||||
SensorDeviceClass.ABSOLUTE_HUMIDITY: (UnitOfDensity.GRAMS_PER_CUBIC_METER, 1),
|
||||
SensorDeviceClass.APPARENT_POWER: (UnitOfApparentPower.VOLT_AMPERE, 0),
|
||||
SensorDeviceClass.AREA: (UnitOfArea.SQUARE_CENTIMETERS, 0),
|
||||
SensorDeviceClass.ATMOSPHERIC_PRESSURE: (UnitOfPressure.PA, 0),
|
||||
@@ -891,8 +886,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³": CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
|
||||
"\u00b5g/m³": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
"\u00b5g/ft³": UnitOfDensity.MICROGRAMS_PER_CUBIC_FOOT,
|
||||
"\u00b5g/m³": UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
"\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light
|
||||
"\u00b5g": UnitOfMass.MICROGRAMS,
|
||||
"\u00b5s": UnitOfTime.MICROSECONDS,
|
||||
|
||||
@@ -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}"
|
||||
self._attr_unique_id = f"{bed.id}-light-{light.outlet_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@override
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -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"
|
||||
self._attr_unique_id = f"{coordinator.unique_id}-infrared-emitter" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
|
||||
|
||||
@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": "silver",
|
||||
"requirements": ["pysmlight==0.5.0", "bleak-smlight==1.1.0"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pysmlight==0.5.2", "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: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
||||
@@ -42,7 +42,7 @@ from .coordinator import (
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
PLATFORMS = [Platform.CALENDAR, Platform.SENSOR]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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,6 +1,7 @@
|
||||
"""Config flow for Steam integration."""
|
||||
|
||||
from collections.abc import Iterator, Mapping
|
||||
import logging
|
||||
from typing import Any, override
|
||||
|
||||
import steam.api
|
||||
@@ -17,9 +18,12 @@ 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, LOGGER, PLACEHOLDERS
|
||||
from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, 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
|
||||
|
||||
@@ -75,8 +79,8 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = (
|
||||
"invalid_auth" if "403" in str(ex) else "cannot_connect"
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unknown exception")
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown exception")
|
||||
errors["base"] = "unknown"
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
@@ -129,8 +133,8 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = (
|
||||
"invalid_auth" if "403" in str(ex) else "cannot_connect"
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unknown exception")
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
if not errors:
|
||||
@@ -166,7 +170,6 @@ 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,6 +1,5 @@
|
||||
"""Steam constants."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
CONF_ACCOUNT = "account"
|
||||
@@ -10,7 +9,6 @@ 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,10 +1,11 @@
|
||||
"""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
|
||||
@@ -12,65 +13,116 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_ACCOUNTS, DOMAIN, LOGGER
|
||||
from .const import CONF_ACCOUNTS, DOMAIN
|
||||
|
||||
type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
class SteamDataUpdateCoordinator(
|
||||
DataUpdateCoordinator[dict[str, dict[str, str | int]]]
|
||||
):
|
||||
|
||||
@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]]):
|
||||
"""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[int, str] = {}
|
||||
self.player_interface: INTMethod = None
|
||||
self.user_interface: INTMethod = None
|
||||
steam.api.key.set(self.config_entry.data[CONF_API_KEY])
|
||||
self.game_icons: dict[str, str] = {}
|
||||
|
||||
def _update(self) -> dict[str, dict[str, str | int]]:
|
||||
@override
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
|
||||
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]:
|
||||
"""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"]: player
|
||||
player["steamid"]: PlayerData(
|
||||
**player,
|
||||
level=self.player_interface.GetSteamLevel(steamid=player["steamid"])[
|
||||
"response"
|
||||
].get("player_level"),
|
||||
)
|
||||
for player in response["response"]["players"]["player"]
|
||||
if player["steamid"] in _ids
|
||||
}
|
||||
for value in players.values():
|
||||
data = self.player_interface.GetSteamLevel(steamid=value["steamid"])
|
||||
value["level"] = data["response"].get("player_level")
|
||||
|
||||
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}
|
||||
)
|
||||
|
||||
return players
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> dict[str, dict[str, str | int]]:
|
||||
async def _async_update_data(self) -> dict[str, PlayerData]:
|
||||
"""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:
|
||||
if "401" in str(ex):
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
raise UpdateFailed(ex) from 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user