Compare commits

..

61 Commits

Author SHA1 Message Date
J. Nick Koston b372e12afd Clarify the apt timeout tradeoff in the venv composite 2026-06-30 11:37:48 -05:00
J. Nick Koston 964963d57e Merge python setup into the venv composite
Each venv job now calls one composite that sets up uv and the managed
interpreter, then restores or builds the venv, instead of two separate steps;
gen-copilot-instructions keeps the standalone setup since it needs no venv. The
composite also documents that apt cannot take a per step timeout.
2026-06-30 11:27:10 -05:00
J. Nick Koston a4b3d48130 Bump setup-uv to v8.2.0 in the composite 2026-06-30 11:15:18 -05:00
J. Nick Koston 568ece2dca Run gen_copilot_instructions through uv managed interpreter 2026-06-30 11:15:17 -05:00
J. Nick Koston 8ab76b839e Use the venv composite in the prepare job too 2026-06-30 11:11:21 -05:00
J. Nick Koston 4b53508bed Drop timeout-minutes from composite step (not supported) 2026-06-30 11:07:46 -05:00
J. Nick Koston 92504864a8 Rebuild venv on cache miss instead of hard failing
The repo cache sits at GitHub's 10GB limit, so a freshly saved venv can be
evicted before the validating jobs read it, and fail-on-cache-miss then aborts
the whole run. Restore the venv tolerantly and rebuild it from requirements on
a miss via a shared composite; the prepare job stays the only writer so no
extra cache pressure is added. This also reverts the cache version bump, which
does nothing for eviction.
2026-06-30 11:03:10 -05:00
J. Nick Koston fc2cd48054 Bump CACHE_VERSION to 5 2026-06-30 10:46:20 -05:00
J. Nick Koston caae2c9460 Explain why setup-uv-python eagerly installs the interpreter 2026-06-30 10:35:42 -05:00
J. Nick Koston d66b8a36dd Merge remote-tracking branch 'upstream/dev' into ci-uv-managed-python
# Conflicts:
#	.github/workflows/ci.yaml
2026-06-30 10:35:38 -05:00
Bram Kragten 9424427a55 Update frontend to 20260624.2 (#175208) 2026-06-30 17:28:21 +02:00
Joost Lekkerkerker 94c5700f22 Add Iotorero virtual integration (#175204) 2026-06-30 17:11:09 +02:00
Karl Beecken 6b7d2b34f9 Mark Teltonika IQS async-dependency done (#175199) 2026-06-30 17:05:08 +02:00
Ariel Ebersberger 939dd7abce Add mypy plugin: flag ==/!= between same-enum operands (#171551) 2026-06-30 16:59:31 +02:00
Manu 1a330ca23e Refactor coordinator in Steam integration (#174661) 2026-06-30 16:31:21 +02:00
dependabot[bot] 5a1cc024dd Bump actions/cache from 5.0.5 to 6.1.0 (#175171)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 16:17:32 +02:00
Erik Montnemery 16a6824799 Report errors in numerical entity triggers (#175093) 2026-06-30 15:08:44 +01:00
Ronald van der Meer 8cb101ae85 Fix Duco state end time being rounded by the integration (#175124) 2026-06-30 15:47:06 +02:00
Franck Nijhof 7950469e31 Avoid blocking call in Anthropic client construction (#174690)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-30 15:37:05 +02:00
GSzabados 9b08858bf0 Bump aioecowitt to 2026.6.0 (#174903) 2026-06-30 15:35:09 +02:00
renovate[bot] b0355f8a9e Update mutagen to 1.48.0 (#175158) 2026-06-30 14:43:30 +02:00
Michael 1c1d95652a Consider current connection type in reconnect action in FRITZ!Box Tools (#175129) 2026-06-30 14:32:00 +02:00
epenet d1275c1128 Deprecate CONCENTRATION_* constants (#175189) 2026-06-30 14:22:23 +02:00
Markus Tuominen 9c34477559 Add entity-unique-id-redundant-platform pylint check (#174442) 2026-06-30 14:34:59 +03:00
TimL 8fc2c24ea0 Bump SMLIGHT to platinum quality (#175182)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-30 13:13:47 +03:00
seanpasino 638f081a6d Add calendar platform to Sonarr (#174506)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-06-30 11:36:16 +02:00
Hai-Nam Nguyen d99e54a14b Add Hypontech plant status binary sensor (#174404)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:56:10 +02:00
dependabot[bot] a2e00eb0b5 Bump actions/cache/save from 5.0.5 to 6.1.0 (#175168)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 10:34:47 +02:00
dependabot[bot] 0ea1ec6fc7 Bump actions/cache/restore from 5.0.5 to 6.1.0 (#175172)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 10:30:31 +02:00
Erik Montnemery de6b679e6e Rewrite homeassistant started trigger (#175160)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-30 10:27:31 +02:00
epenet 3bcdb621ec Use UnitOfDensity/UnitOfRatio in tests (#175187) 2026-06-30 10:27:18 +02:00
epenet 003aed3d44 Set override decorator in switchbot (#175190) 2026-06-30 10:25:37 +02:00
Onero-testdev 1d0beeeef9 Add SwitchBot Standing Fan select platform (#173580)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:01:34 +02:00
TimL f0850b67f2 Bump pysmlight to 0.5.2 (#175179) 2026-06-30 11:00:28 +03:00
Oscar Calvo fbd83f8a52 Add quality scale (bronze) to ccm15 (#173801) 2026-06-30 10:21:07 +03:00
epenet bc18d0678a Use UnitOfDensity/UnitOfRatio in entity components (#175169) 2026-06-30 09:10:02 +02:00
TimL c0971e99e9 add smlight to bluetooth_adapters so its loaded before integrations (#175176) 2026-06-30 09:07:41 +02:00
epenet 771da03707 Use UnitOfDensity/UnitOfRatio in screenlogic (#175175) 2026-06-30 09:06:53 +02:00
Erik Montnemery 80a04d2820 Make did_not_trigger of async_initialize_triggers kwarg only (#175178) 2026-06-30 08:55:59 +02:00
Greg Haines feadcc057d Add diagnostics for CentriConnect (#175046) 2026-06-30 08:36:10 +02:00
epenet cf1e0f0aa5 Use UnitOfDensity/UnitOfRatio in kaiterra (#175174) 2026-06-30 08:35:13 +02:00
epenet cfa49bb0dd Use UnitOfDensity/UnitOfRatio in isy994 (#175173) 2026-06-30 08:32:05 +02:00
Erik Montnemery a0d96a2c62 Remove no longer needed sleep from HomeAssistant.async_start (#175152) 2026-06-30 06:36:25 +02:00
renovate[bot] eb9ebd17b6 Update coverage to 7.14.3 (#175155) 2026-06-30 06:32:50 +02:00
Robert Resch 5ea38fcc07 Bump uv to 0.11.25 (#175153) 2026-06-30 06:27:59 +02:00
Robert Resch 15ef228cfa Bump wheels and base image to 2026.07.0 to use alpine 3.24 (#175133) 2026-06-29 23:41:01 +02:00
Arie Catsman e815c9f0cc bump pyenphase to 3.0.1 (#175141) 2026-06-29 23:31:43 +03:00
Simone Chemelli 434b3ca309 Bump librouteros to 4.1.1 (#175116) 2026-06-29 20:04:32 +01:00
Markus Tuominen a557e96c53 Add walk_checker helper to deduplicate pylint rule tests (#175113) 2026-06-29 20:41:07 +02:00
Jan Bouwhuis 324c95140b Refactor MQTT config entry (#173929)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
2026-06-29 20:07:26 +02:00
J. Nick Koston 51cddb88f5 Merge remote-tracking branch 'upstream/dev' into ci-uv-managed-python
# Conflicts:
#	.github/workflows/ci.yaml
2026-05-26 18:24:40 -05:00
J. Nick Koston 748a9842af Merge branch 'ci-cache-postgres-mariadb-deps' into ci-uv-managed-python 2026-05-21 14:33:44 -05:00
J. Nick Koston 55786dbdfc Use dpkg-architecture to derive multiarch lib path
So the ldconfig workaround also works on non-x86_64 runners.
2026-05-21 14:32:58 -05:00
J. Nick Koston e88c03a437 Merge branch 'dev' into ci-cache-postgres-mariadb-deps 2026-05-21 13:37:03 -05:00
J. Nick Koston dbc0dc1ea6 Install Python eagerly via setup-uv-python composite
setup-uv only sets UV_PYTHON, it does not actually fetch the
interpreter; uv installs it lazily on the first 'uv venv' or
'uv pip install'. When the venv cache hits, no uv command runs, so
the cached venv's bin/python3 symlink points at an interpreter that
was never installed in this job and the next step that activates the
venv aborts with 'broken symlink'.

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

Bumps CACHE_VERSION so the old setup-python venv caches are invalidated;
the venv symlinks would otherwise point at the absent hostedtoolcache
interpreter.
2026-05-21 13:05:30 -05:00
J. Nick Koston 3f0c93c26c Merge branch 'dev' into ci-cache-postgres-mariadb-deps 2026-05-21 12:48:19 -05:00
J. Nick Koston 07ed913ba2 Extract apt caching into composite action with alternatives workaround
Wrap awalsh128/cache-apt-pkgs-action in .github/actions/cache-apt-packages
so every job uses the same pattern, and route /usr/lib/x86_64-linux-gnu
subdirectories through ldconfig. The upstream action does not run postinst
on cache restore so update-alternatives symlinks (libblas, liblapack via
ffmpeg) never appear; adding the subdirs to ld.so.conf.d lets the linker
find the real libraries without those symlinks.
2026-05-21 10:45:13 -05:00
J. Nick Koston b7905b163f Run ldconfig after cache-apt-pkgs-action restore
The action restores cached .deb files to disk but skips dpkg-trigger so
/etc/ld.so.cache stays stale and ctypes-based loaders (eg opuslib)
cannot find libopus.so.0. Add an explicit ldconfig step after each
action call.
2026-05-21 10:02:39 -05:00
J. Nick Koston c712b07da3 Switch CI apt caching to awalsh128/cache-apt-pkgs-action 2026-05-21 09:42:20 -05:00
277 changed files with 5018 additions and 2710 deletions
@@ -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}"
+1 -1
View File
@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.05.0"
BASE_IMAGE_VERSION: "2026.07.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
+93 -236
View File
@@ -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: >-
@@ -838,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 }}
@@ -903,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"
@@ -1045,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"
@@ -1201,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"
@@ -1369,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"
+2 -2
View File
@@ -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
-8
View File
@@ -1,9 +1 @@
"""Init file for Home Assistant."""
from probatio.compat import install_as_voluptuous
# Probatio replaces voluptuous as the validation engine. Custom integrations and a
# few dependencies still import voluptuous directly, so alias it to probatio in
# sys.modules before anything imports it. This must run before the first
# `import voluptuous`, hence the package __init__.
install_as_voluptuous()
+1 -1
View File
@@ -108,7 +108,7 @@ class AdGuardHomeSensor(AdGuardHomeEntity, SensorEntity):
"""Initialize AdGuard Home sensor."""
super().__init__(data, entry)
self.entity_description = description
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain
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,
+1 -1
View File
@@ -103,7 +103,7 @@ class AdGuardHomeSwitch(AdGuardHomeEntity, SwitchEntity):
"""Initialize AdGuard Home switch."""
super().__init__(data, entry)
self.entity_description = description
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain
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,
+1 -1
View File
@@ -46,7 +46,7 @@ class AdGuardHomeUpdate(AdGuardHomeEntity, UpdateEntity):
"""Initialize AdGuard Home update."""
super().__init__(data, entry)
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain
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,
),
}
+27 -33
View File
@@ -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
+1 -1
View File
@@ -56,7 +56,7 @@ class AirOSUpdateEntity(AirOSEntity, UpdateEntity):
self.status = status
self.firmware = firmware
self._attr_unique_id = f"{status.data.derived.mac}_firmware_update"
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],
@@ -6,8 +6,8 @@ import logging
from typing import TYPE_CHECKING, Any, cast, override
import anthropic
from probatio import to_openapi as convert
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components.zone import ENTITY_ID_HOME
from homeassistant.config_entries import (
@@ -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
+1 -1
View File
@@ -103,8 +103,8 @@ from anthropic.types.web_fetch_tool_result_block import (
from anthropic.types.web_fetch_tool_result_block_param import (
Content as WebFetchToolResultBlockParamContentParam,
)
from probatio import to_openapi as convert
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
+2 -2
View File
@@ -74,8 +74,8 @@ from ipaddress import ip_address
from typing import TYPE_CHECKING, Any, cast
from aiohttp import web
from probatio import serialize
import voluptuous as vol
import voluptuous_serialize
from homeassistant import data_entry_flow
from homeassistant.auth import AuthManagerFlowManager, InvalidAuthError
@@ -263,7 +263,7 @@ def _prepare_result_json(result: AuthFlowResult) -> dict[str, Any]:
if (schema := result["data_schema"]) is None:
data["data_schema"] = []
else:
data["data_schema"] = serialize(schema)
data["data_schema"] = voluptuous_serialize.convert(schema)
return data
@@ -3,8 +3,8 @@
import logging
from typing import Any, override
from probatio import serialize
import voluptuous as vol
import voluptuous_serialize
from homeassistant import data_entry_flow
from homeassistant.components import websocket_api
@@ -153,6 +153,6 @@ def _prepare_result_json(result: data_entry_flow.FlowResult) -> dict[str, Any]:
if (schema := result["data_schema"]) is None:
data["data_schema"] = []
else:
data["data_schema"] = serialize(schema)
data["data_schema"] = voluptuous_serialize.convert(schema)
return data
+12 -15
View File
@@ -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,
)
+1 -1
View File
@@ -34,7 +34,7 @@ class AutomaticBackupEvent(BackupManagerBaseEntity, EventEntity):
def __init__(self, coordinator: BackupDataUpdateCoordinator) -> None:
"""Initialize the automatic backup event."""
super().__init__(coordinator)
self._attr_unique_id = "automatic_backup_event" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
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
+1 -1
View File
@@ -56,7 +56,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
super().__init__(coordinator)
Camera.__init__(self)
self._camera = camera
self._attr_unique_id = f"{camera.serial}-camera"
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,
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -36,7 +36,7 @@ class BroadlinkTime(BroadlinkEntity, TimeEntity):
"""Initialize the sensor."""
super().__init__(device)
self._attr_unique_id = f"{device.unique_id}-device_time"
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:
+2 -2
View File
@@ -93,9 +93,9 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
# Backward compatible unique ID: circuit 1 keeps old format
if circuit == 1:
self._attr_unique_id = f"{mac}-climate"
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.
+1 -1
View File
@@ -42,8 +42,8 @@ from openai.types.responses.response_input_param import (
ImageGenerationCall as ImageGenerationCallParam,
)
from openai.types.responses.response_output_item import ImageGenerationCall
from probatio import to_openapi as convert
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry
+21 -4
View File
@@ -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
+10 -2
View File
@@ -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
+1 -1
View File
@@ -149,7 +149,7 @@ class DemoWeather(WeatherEntity):
) -> None:
"""Initialize the Demo weather."""
self._attr_name = f"Demo Weather {name}"
self._attr_unique_id = f"demo-weather-{name.lower()}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
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
@@ -9,8 +9,8 @@ import logging
from types import ModuleType
from typing import TYPE_CHECKING, Any, Literal, overload
from probatio import serialize
import voluptuous as vol
import voluptuous_serialize
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ActiveConnection
@@ -318,7 +318,7 @@ async def _async_get_device_automation_capabilities(
if (extra_fields := capabilities.get("extra_fields")) is None:
capabilities["extra_fields"] = []
else:
capabilities["extra_fields"] = serialize(
capabilities["extra_fields"] = voluptuous_serialize.convert(
extra_fields, custom_serializer=cv.custom_serializer
)
+10 -2
View File
@@ -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
+1 -3
View File
@@ -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
),
+1 -1
View File
@@ -31,7 +31,7 @@ class EcobeeNotifyEntity(EcobeeBaseEntity, NotifyEntity):
"""Initialize the thermostat."""
super().__init__(data, thermostat_index)
self._attr_unique_id = (
f"{self.thermostat['identifier']}_notify_{thermostat_index}"
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."
+6 -1
View File
@@ -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
+1 -1
View File
@@ -40,7 +40,7 @@ class FibaroScene(Scene):
self._attr_name = f"{room_name} {fibaro_scene.name}"
self._attr_unique_id = (
f"{slugify(controller.hub_serial)}.scene.{fibaro_scene.fibaro_id}"
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
+14 -5
View File
@@ -11,7 +11,7 @@ from typing import Any, TypedDict, cast, override
from xml.etree.ElementTree import ParseError
from fritzconnection import FritzConnection
from fritzconnection.core.exceptions import FritzActionError
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(
@@ -31,8 +31,8 @@ from google.genai.types import (
Tool,
ToolListUnion,
)
from probatio import to_openapi as convert
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
@@ -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"
+1 -1
View File
@@ -36,7 +36,7 @@ class ImmichUpdateEntity(ImmichEntity, UpdateEntity):
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_update"
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
@@ -2,13 +2,13 @@
from typing import Any, TypedDict
from probatio import serialize
from pyinsteon import async_close, async_connect, devices
from pyinsteon.address import Address
from pyinsteon.aldb.aldb_record import ALDBRecord
from pyinsteon.constants import LinkStatus
from pyinsteon.managers.link_manager import get_broken_links
import voluptuous as vol
import voluptuous_serialize
from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
@@ -212,10 +212,12 @@ async def websocket_get_modem_schema(
config_data = config_entry.data
if device := config_data.get(CONF_DEVICE):
ports = await async_get_usb_ports(hass=hass)
plm_schema = serialize(build_plm_schema(ports=ports, device=device))
plm_schema = voluptuous_serialize.convert(
build_plm_schema(ports=ports, device=device)
)
connection.send_result(msg[ID], plm_schema)
else:
hub_schema = serialize(build_hub_schema(**config_data))
hub_schema = voluptuous_serialize.convert(build_hub_schema(**config_data))
connection.send_result(msg[ID], hub_schema)
@@ -2,7 +2,6 @@
from typing import Any
from probatio import serialize
from pyinsteon import devices
from pyinsteon.config import (
LOAD_BUTTON,
@@ -19,6 +18,7 @@ from pyinsteon.constants import (
)
from pyinsteon.device_types.device_base import Device
import voluptuous as vol
import voluptuous_serialize
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
@@ -43,26 +43,26 @@ RELAY_MODES = [str(RelayMode(v)).lower() for v in list(RelayMode)]
def _bool_schema(name):
return serialize(vol.Schema({vol.Required(name): bool}))[0]
return voluptuous_serialize.convert(vol.Schema({vol.Required(name): bool}))[0]
def _byte_schema(name):
return serialize(vol.Schema({vol.Required(name): cv.byte}))[0]
return voluptuous_serialize.convert(vol.Schema({vol.Required(name): cv.byte}))[0]
def _float_schema(name):
return serialize(vol.Schema({vol.Required(name): float}))[0]
return voluptuous_serialize.convert(vol.Schema({vol.Required(name): float}))[0]
def _list_schema(name, values):
return serialize(
return voluptuous_serialize.convert(
vol.Schema({vol.Required(name): vol.In(values)}),
custom_serializer=cv.custom_serializer,
)[0]
def _multi_select_schema(name, values):
return serialize(
return voluptuous_serialize.convert(
vol.Schema({vol.Optional(name): cv.multi_select(values)}),
custom_serializer=cv.custom_serializer,
)[0]
@@ -70,7 +70,7 @@ def _multi_select_schema(name, values):
def _read_only_schema(name, value):
"""Return a constant value schema."""
return serialize(vol.Schema({vol.Required(name): value}))[0]
return voluptuous_serialize.convert(vol.Schema({vol.Required(name): value}))[0]
def get_schema(prop, name, groups):
@@ -0,0 +1 @@
"""Virtual integration: IoTorero."""
@@ -0,0 +1,6 @@
{
"domain": "iotorero",
"name": "IoTorero",
"integration_type": "virtual",
"supported_by": "esphome"
}
+7 -8
View File
@@ -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
+6 -13
View File
@@ -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"]
+1 -1
View File
@@ -114,7 +114,7 @@ async def _get_coordinator(
for entry_id in device.config_entries:
entry = call.hass.config_entries.async_get_entry(entry_id)
if entry and entry.domain == DOMAIN:
if entry.state != 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
@@ -1,6 +1,6 @@
"""Selectors for KNX."""
from collections.abc import Iterable
from collections.abc import Hashable, Iterable
from enum import Enum
from typing import Any, override
@@ -23,7 +23,7 @@ class AllSerializeFirst(vol.All):
class KNXSelectorBase:
"""Base class for KNX selectors supporting optional nested schemas."""
schema: vol.Schema | vol.Any | vol.All | GroupSelectSchema
schema: vol.Schema | vol.Any | vol.All
selector_type: str
# mark if self.schema should be serialized to `schema` key
serialize_subschema: bool = False
@@ -108,35 +108,30 @@ class GroupSelectOption(KNXSelectorBase):
}
class GroupSelectSchema:
"""Use the first validated value, like ``vol.Any``.
class GroupSelectSchema(vol.Any):
"""Use the first validated value.
A standalone validator rather than a ``vol.Any`` subclass, so it does not
reach into validation-engine internals. On total failure it raises the most
useful branch error (the first that is not an unknown-key error, else the
first) so the UI marks a real problem instead of an extra key.
This is a version of vol.Any with custom error handling to
show proper invalid markers for sub-schema items in the UI.
"""
def __init__(self, *options: vol.Schemable, msg: str | None = None) -> None:
"""Store the options to try in order."""
self.validators = options
self.msg = msg
self._compiled = [vol.Schema(option) for option in options]
def __call__(self, data: Any) -> Any:
"""Return the first option that validates, else raise the best error."""
@override
def _exec(self, funcs: Iterable, v: Any, path: list[Hashable] | None = None) -> Any:
"""Execute the validation functions."""
errors: list[vol.Invalid] = []
for option in self._compiled:
for func in funcs:
try:
return option(data)
except vol.Invalid as err:
errors.append(err)
if path is None:
return func(v)
return func(path, v)
except vol.Invalid as e:
errors.append(e)
if errors:
raise next(
(err for err in errors if err.code != "extra_keys_not_allowed"),
(err for err in errors if "extra keys not allowed" not in err.msg),
errors[0],
)
raise vol.AnyInvalid(self.msg or "no valid value found")
raise vol.AnyInvalid(self.msg or "no valid value found", path=path)
class GroupSelect(KNXSelectorBase):
@@ -2,7 +2,8 @@
from typing import Any, cast
from probatio import UNSUPPORTED, serialize as convert
import voluptuous as vol
from voluptuous_serialize import UNSUPPORTED, UnsupportedType, convert
from homeassistant.const import Platform
from homeassistant.helpers import selector
@@ -11,7 +12,9 @@ from .entity_store_schema import KNX_SCHEMA_FOR_PLATFORM
from .knx_selector import AllSerializeFirst, GroupSelectSchema, KNXSelectorBase
def knx_serializer(schema: Any) -> Any:
def knx_serializer(
schema: vol.Schema,
) -> dict[str, Any] | list[dict[str, Any]] | UnsupportedType:
"""Serialize KNX schema."""
if isinstance(schema, GroupSelectSchema):
return [
@@ -40,8 +43,5 @@ def get_serialized_schema(
) -> dict[str, Any] | list[dict[str, Any]] | None:
"""Get the schema for a specific platform."""
if knx_schema := KNX_SCHEMA_FOR_PLATFORM.get(platform):
return cast(
"dict[str, Any] | list[dict[str, Any]]",
convert(knx_schema, custom_serializer=knx_serializer),
)
return convert(knx_schema, custom_serializer=knx_serializer)
return None
+1 -1
View File
@@ -33,7 +33,7 @@ class LaMetricUpdate(LaMetricEntity, UpdateEntity):
def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.data.serial_number}-update"
self._attr_unique_id = f"{coordinator.data.serial_number}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
+1 -1
View File
@@ -69,7 +69,7 @@ class LiebherrPresentationLight(LiebherrEntity, LightEntity):
) -> None:
"""Initialize the presentation light entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.device_id}_presentation_light"
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:
+1 -1
View File
@@ -12,8 +12,8 @@ from mcp import McpError
from mcp.client.session import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
from probatio import from_openapi as convert_to_voluptuous
import voluptuous as vol
from voluptuous_openapi import convert_to_voluptuous
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
@@ -15,9 +15,9 @@ from typing import Any, cast
from mcp import types
from mcp.server import Server
from mcp.server.lowlevel.helper_types import ReadResourceContents
from probatio import to_openapi as convert
from pydantic import AnyUrl
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -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"]
}
@@ -14,7 +14,6 @@ from aiohttp.web import HTTPBadRequest, Request, Response, json_response
from nacl.exceptions import CryptoError
from nacl.secret import SecretBox
import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant.components import (
camera,
@@ -160,7 +159,7 @@ def validate_schema(schema):
try:
data = schema(data)
except vol.Invalid as ex:
err = humanize_error(data, ex)
err = vol.humanize.humanize_error(data, ex)
_LOGGER.error("Received invalid webhook payload: %s", err)
return empty_okay_response()
@@ -201,7 +200,7 @@ async def handle_webhook(
try:
req_data = WEBHOOK_PAYLOAD_SCHEMA(req_data)
except vol.Invalid as ex:
err = humanize_error(req_data, ex)
err = vol.humanize.humanize_error(req_data, ex)
_LOGGER.error(
"Received invalid webhook from %s with payload: %s", device_name, err
)
@@ -649,7 +648,7 @@ async def webhook_update_sensor_states(
try:
sensor = SENSOR_SCHEMA_FULL(sensor)
except vol.Invalid as err:
err_msg = humanize_error(sensor, err)
err_msg = vol.humanize.humanize_error(sensor, err)
_LOGGER.error(
"Received invalid sensor payload from %s for %s: %s",
device_name,
+1 -2
View File
@@ -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(
+251 -365
View File
@@ -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
-1
View File
@@ -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
+36 -71
View File
@@ -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",
+1 -1
View File
@@ -179,7 +179,7 @@ class TemperatureSensor(BaseSensorEntity):
"""Initialize TemperatureSensor entity."""
super().__init__(coordinator)
self._temp_sensor = nasweb_temp_sensor
self._attr_unique_id = f"{DOMAIN}.{self._temp_sensor.webio_serial}.temp_sensor" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
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)}
)
+1 -1
View File
@@ -96,7 +96,7 @@ class RelaySwitch(SwitchEntity, BaseCoordinatorEntity):
self._attr_translation_key = OUTPUT_TRANSLATION_KEY
self._attr_translation_placeholders = {"index": f"{nasweb_output.index:2d}"}
self._attr_unique_id = (
f"{DOMAIN}.{self._output.webio_serial}.relay_switch.{self._output.index}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
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)},
+1 -1
View File
@@ -151,7 +151,7 @@ class NestCameraBaseEntity(Camera, ABC):
self._attr_model = nest_device_info.device_model
self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3
# The API "name" field is a unique device identifier.
self._attr_unique_id = f"{self._device.name}-camera"
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:
+2 -2
View File
@@ -74,7 +74,7 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity):
def __init__(self, netatmo_device: NetatmoDevice) -> None:
"""Initialize a Netatmo Presence camera light."""
super().__init__(netatmo_device)
self._attr_unique_id = f"{self.device.entity_id}-light"
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
+1 -1
View File
@@ -69,7 +69,7 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity):
configuration_url=CONF_URL_ENERGY,
)
self._attr_unique_id = f"{self.home.entity_id}-schedule-select"
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
+1 -1
View File
@@ -43,7 +43,7 @@ class NetgearUpdateEntity(
) -> None:
"""Initialize a Netgear device."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.router.serial_number}-update"
self._attr_unique_id = f"{coordinator.router.serial_number}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
+34 -39
View File
@@ -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,
+1 -1
View File
@@ -6,8 +6,8 @@ import logging
from typing import Any
import ollama
from probatio import to_openapi as convert
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
@@ -22,8 +22,8 @@ from openai.types.chat import (
from openai.types.chat.chat_completion_message_function_tool_call_param import Function
from openai.types.shared_params import FunctionDefinition, ResponseFormatJSONSchema
from openai.types.shared_params.response_format_json_schema import JSONSchema
from probatio import to_openapi as convert
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
@@ -6,8 +6,8 @@ import logging
from typing import Any, override
import openai
from probatio import to_openapi as convert
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components.zone import ENTITY_ID_HOME
from homeassistant.config_entries import (
@@ -56,8 +56,8 @@ from openai.types.responses.tool_param import (
ImageGeneration,
)
from openai.types.responses.web_search_tool_param import UserLocation
from probatio import to_openapi as convert
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
+1 -1
View File
@@ -51,7 +51,7 @@ class OpenhomeUpdateEntity(UpdateEntity):
def __init__(self, device):
"""Initialize a Linn DS update entity."""
self._device = device
self._attr_unique_id = f"{device.uuid()}-update"
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:
@@ -18,7 +18,7 @@ from openai.types.chat import (
)
from openai.types.chat.chat_completion_message_function_tool_call_param import Function
from openai.types.shared_params import FunctionDefinition
from probatio import to_openapi as convert
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
+1 -1
View File
@@ -80,7 +80,7 @@ class PlexSensor(SensorEntity):
def __init__(self, hass, plex_server):
"""Initialize the sensor."""
self._attr_unique_id = f"sensor-{plex_server.machine_identifier}"
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(
+1 -1
View File
@@ -102,7 +102,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
) -> None:
"""Set up the Plugwise API."""
super().__init__(coordinator, device_id)
self._attr_unique_id = f"{device_id}-climate"
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()
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, override
from pushbullet import PushBullet, PushError
from pushbullet.channel import Channel
from pushbullet.device import Device
import voluptuous as vol
from homeassistant.components.notify import (
ATTR_DATA,
@@ -143,7 +144,7 @@ class PushBulletNotificationService(BaseNotificationService):
raise ValueError("Cannot send an empty file")
kwargs.update(filedata)
pusher.push_file(**kwargs)
elif file_url := data.get(ATTR_FILE_URL):
elif (file_url := data.get(ATTR_FILE_URL)) and vol.Url(file_url):
pusher.push_file(
file_name=file_url,
file_url=file_url,
+1 -1
View File
@@ -65,7 +65,7 @@ class RachioCalendarEntity(
self._attr_translation_placeholders = {
"base": coordinator.base_station[KEY_SERIAL_NUMBER]
}
self._attr_unique_id = f"{coordinator.base_station[KEY_ID]}-calendar"
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
+1 -1
View File
@@ -40,7 +40,7 @@ class LeilSaunaLight(LeilSaunaEntity, LightEntity):
"""Initialize the light entity."""
super().__init__(coordinator)
# Override unique_id to differentiate from climate entity
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_light"
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,
}

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