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
210 changed files with 4795 additions and 2480 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
+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],
@@ -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
+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.
+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
+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(
@@ -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
@@ -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 -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:
@@ -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"]
}
+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
@@ -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:
+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()
+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,
}
+35 -40
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,
@@ -635,22 +630,22 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.ABSOLUTE_HUMIDITY: {
CONCENTRATION_GRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
UnitOfDensity.GRAMS_PER_CUBIC_METER,
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.APPARENT_POWER: set(UnitOfApparentPower),
SensorDeviceClass.AQI: {None},
SensorDeviceClass.AREA: set(UnitOfArea),
SensorDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure),
SensorDeviceClass.BATTERY: {PERCENTAGE},
SensorDeviceClass.BATTERY: {UnitOfRatio.PERCENTAGE},
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
SensorDeviceClass.CO: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
UnitOfRatio.PARTS_PER_BILLION,
UnitOfRatio.PARTS_PER_MILLION,
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION},
SensorDeviceClass.CO2: {UnitOfRatio.PARTS_PER_MILLION},
SensorDeviceClass.CONDUCTIVITY: set(UnitOfConductivity),
SensorDeviceClass.CURRENT: set(UnitOfElectricCurrent),
SensorDeviceClass.DATA_RATE: set(UnitOfDataRate),
@@ -675,31 +670,31 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
UnitOfVolume.LITERS,
UnitOfVolume.MILLE_CUBIC_FEET,
},
SensorDeviceClass.HUMIDITY: {PERCENTAGE},
SensorDeviceClass.HUMIDITY: {UnitOfRatio.PERCENTAGE},
SensorDeviceClass.ILLUMINANCE: {LIGHT_LUX},
SensorDeviceClass.IRRADIANCE: set(UnitOfIrradiance),
SensorDeviceClass.MOISTURE: {PERCENTAGE},
SensorDeviceClass.MOISTURE: {UnitOfRatio.PERCENTAGE},
SensorDeviceClass.NITROGEN_DIOXIDE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
UnitOfRatio.PARTS_PER_BILLION,
UnitOfRatio.PARTS_PER_MILLION,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.NITROGEN_MONOXIDE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
UnitOfRatio.PARTS_PER_BILLION,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.NITROUS_OXIDE: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.OZONE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
UnitOfRatio.PARTS_PER_BILLION,
UnitOfRatio.PARTS_PER_MILLION,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.PH: {None},
SensorDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM4: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.POWER_FACTOR: {PERCENTAGE, None},
SensorDeviceClass.PM1: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM10: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM25: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM4: {UnitOfDensity.MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.POWER_FACTOR: {UnitOfRatio.PERCENTAGE, None},
SensorDeviceClass.POWER: {
UnitOfPower.MILLIWATT,
UnitOfPower.WATT,
@@ -720,18 +715,18 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure),
SensorDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux},
SensorDeviceClass.SULPHUR_DIOXIDE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
UnitOfRatio.PARTS_PER_BILLION,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.TEMPERATURE: set(UnitOfTemperature),
SensorDeviceClass.TEMPERATURE_DELTA: set(UnitOfTemperature),
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
UnitOfRatio.PARTS_PER_BILLION,
UnitOfRatio.PARTS_PER_MILLION,
},
SensorDeviceClass.VOLTAGE: set(UnitOfElectricPotential),
SensorDeviceClass.VOLUME: set(UnitOfVolume),
@@ -758,7 +753,7 @@ DEFAULT_PRECISION_LIMIT = 2
# have 0 decimals, that one should be used and not mW, even though mW also should have
# 0 decimals. Otherwise the smaller units will have more decimals than expected.
UNITS_PRECISION = {
SensorDeviceClass.ABSOLUTE_HUMIDITY: (CONCENTRATION_GRAMS_PER_CUBIC_METER, 1),
SensorDeviceClass.ABSOLUTE_HUMIDITY: (UnitOfDensity.GRAMS_PER_CUBIC_METER, 1),
SensorDeviceClass.APPARENT_POWER: (UnitOfApparentPower.VOLT_AMPERE, 0),
SensorDeviceClass.AREA: (UnitOfArea.SQUARE_CENTIMETERS, 0),
SensorDeviceClass.ATMOSPHERIC_PRESSURE: (UnitOfPressure.PA, 0),
@@ -891,8 +886,8 @@ AMBIGUOUS_UNITS: dict[str | None, str] = {
"\u00b5Sv/h": "μSv/h", # aranet: radiation rate
"\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM,
"\u00b5V": UnitOfElectricPotential.MICROVOLT,
"\u00b5g/ft³": CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
"\u00b5g/m³": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"\u00b5g/ft³": UnitOfDensity.MICROGRAMS_PER_CUBIC_FOOT,
"\u00b5g/m³": UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
"\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light
"\u00b5g": UnitOfMass.MICROGRAMS,
"\u00b5s": UnitOfTime.MICROSECONDS,
+1 -1
View File
@@ -45,7 +45,7 @@ class SleepIQLightEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], LightEn
self.light = light
super().__init__(coordinator, bed)
self._attr_name = f"SleepNumber {bed.name} Light {light.outlet_id}"
self._attr_unique_id = f"{bed.id}-light-{light.outlet_id}"
self._attr_unique_id = f"{bed.id}-light-{light.outlet_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def async_turn_on(self, **kwargs: Any) -> None:
+1 -1
View File
@@ -37,7 +37,7 @@ class SmInfraredEntity(SmEntity, InfraredEmitterEntity):
def __init__(self, coordinator: SmDataUpdateCoordinator) -> None:
"""Initialize the SLZB-Ultima infrared."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-infrared-emitter"
self._attr_unique_id = f"{coordinator.unique_id}-infrared-emitter" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def async_send_command(self, command: InfraredCommand) -> None:
@@ -12,8 +12,8 @@
"documentation": "https://www.home-assistant.io/integrations/smlight",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["pysmlight==0.5.0", "bleak-smlight==1.1.0"],
"quality_scale": "platinum",
"requirements": ["pysmlight==0.5.2", "bleak-smlight==1.1.0"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."
@@ -54,11 +54,11 @@ rules:
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
+1 -1
View File
@@ -42,7 +42,7 @@ from .coordinator import (
)
from .services import async_setup_services
PLATFORMS = [Platform.SENSOR]
PLATFORMS = [Platform.CALENDAR, Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -0,0 +1,81 @@
"""Support for Sonarr calendar items."""
from datetime import datetime, timedelta
from typing import cast, override
from aiopyarr import SonarrCalendar
from homeassistant.components.calendar import (
CalendarEntity,
CalendarEntityDescription,
CalendarEvent,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import CalendarDataUpdateCoordinator, SonarrConfigEntry
from .entity import SonarrEntity
CALENDAR_TYPE = CalendarEntityDescription(
key="calendar",
name=None,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SonarrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Sonarr calendar entity."""
coordinator = entry.runtime_data.upcoming
async_add_entities([SonarrCalendarEntity(coordinator, CALENDAR_TYPE)])
def _get_calendar_event(episode: SonarrCalendar) -> CalendarEvent:
"""Return a CalendarEvent from a Sonarr calendar item."""
series_title: str = episode.series.title # type: ignore[misc]
runtime: int = episode.series.runtime # type: ignore[misc]
start = dt_util.as_utc(episode.airDateUtc)
summary = (
f"{series_title} - S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}"
)
if episode.title:
summary = f"{summary} - {episode.title}"
return CalendarEvent(
summary=summary,
start=start,
end=start + timedelta(minutes=runtime),
description=getattr(episode, "overview", None) or None,
)
class SonarrCalendarEntity(SonarrEntity[list[SonarrCalendar]], CalendarEntity):
"""A Sonarr calendar entity."""
coordinator: CalendarDataUpdateCoordinator
@property
@override
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
now = dt_util.now()
events = sorted(
(_get_calendar_event(episode) for episode in self.coordinator.data),
key=lambda event: event.start,
)
return next((event for event in events if event.end > now), None)
@override
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
episodes = cast(
list[SonarrCalendar],
await self.coordinator.api_client.async_get_calendar(
start_date=start_date, end_date=end_date, include_series=True
),
)
return [_get_calendar_event(episode) for episode in episodes]
@@ -1,6 +1,7 @@
"""Config flow for Steam integration."""
from collections.abc import Iterator, Mapping
import logging
from typing import Any, override
import steam.api
@@ -17,9 +18,12 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, LOGGER, PLACEHOLDERS
from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, PLACEHOLDERS
from .coordinator import SteamConfigEntry
_LOGGER = logging.getLogger(__name__)
# To avoid too long request URIs, the amount of ids to request is limited
MAX_IDS_TO_REQUEST = 275
@@ -75,8 +79,8 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = (
"invalid_auth" if "403" in str(ex) else "cannot_connect"
)
except Exception: # noqa: BLE001
LOGGER.exception("Unknown exception")
except Exception:
_LOGGER.exception("Unknown exception")
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
@@ -129,8 +133,8 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = (
"invalid_auth" if "403" in str(ex) else "cannot_connect"
)
except Exception: # noqa: BLE001
LOGGER.exception("Unknown exception")
except Exception:
_LOGGER.exception("Unknown exception")
errors["base"] = "unknown"
if not errors:
@@ -166,7 +170,6 @@ class SteamOptionsFlowHandler(OptionsFlowWithReload):
) -> ConfigFlowResult:
"""Manage Steam options."""
if user_input is not None:
await self.hass.config_entries.async_unload(self.config_entry.entry_id)
for _id in self.options[CONF_ACCOUNTS]:
if _id not in user_input[CONF_ACCOUNTS] and (
entity_id := er.async_get(self.hass).async_get_entity_id(
@@ -1,6 +1,5 @@
"""Steam constants."""
import logging
from typing import Final
CONF_ACCOUNT = "account"
@@ -10,7 +9,6 @@ DATA_KEY_COORDINATOR = "coordinator"
DEFAULT_NAME = "Steam"
DOMAIN: Final = "steam_online"
LOGGER = logging.getLogger(__package__)
PLACEHOLDERS = {
"api_key_url": "https://steamcommunity.com/dev/apikey",
@@ -1,10 +1,11 @@
"""Data update coordinator for the Steam integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import override
import steam.api
from steam.api import _interface_method as INTMethod
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
@@ -12,65 +13,116 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_ACCOUNTS, DOMAIN, LOGGER
from .const import CONF_ACCOUNTS, DOMAIN
type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator]
_LOGGER = logging.getLogger(__name__)
class SteamDataUpdateCoordinator(
DataUpdateCoordinator[dict[str, dict[str, str | int]]]
):
@dataclass(kw_only=True, frozen=True)
class PlayerData:
"""Steam player data."""
steamid: str
communityvisibilitystate: int
profilestate: int
personaname: str
commentpermission: int | None = None
profileurl: str
avatar: str
avatarmedium: str
avatarfull: str
avatarhash: str
lastlogoff: int
personastate: int
realname: str | None = None
primaryclanid: str | None = None
timecreated: int | None = None
personastateflags: int
loccountrycode: str | None = None
locstatecode: str | None = None
loccityid: int | None = None
gameextrainfo: str | None = None
gameid: str | None = None
level: int | None = None
class SteamDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PlayerData]]):
"""Data update coordinator for the Steam integration."""
config_entry: SteamConfigEntry
user_interface: steam.api.interface
player_interface: steam.api.interface
def __init__(self, hass: HomeAssistant, config_entry: SteamConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass=hass,
logger=LOGGER,
logger=_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(seconds=30),
)
self.game_icons: dict[int, str] = {}
self.player_interface: INTMethod = None
self.user_interface: INTMethod = None
steam.api.key.set(self.config_entry.data[CONF_API_KEY])
self.game_icons: dict[str, str] = {}
def _update(self) -> dict[str, dict[str, str | int]]:
@override
async def _async_setup(self) -> None:
"""Set up the coordinator."""
steam.api.key.set(self.config_entry.data[CONF_API_KEY])
self.user_interface = steam.api.interface("ISteamUser")
self.player_interface = steam.api.interface("IPlayerService")
def _update(self) -> dict[str, PlayerData]:
"""Fetch data from API endpoint."""
accounts = self.config_entry.options[CONF_ACCOUNTS]
_ids = list(accounts)
if not self.user_interface or not self.player_interface:
self.user_interface = steam.api.interface("ISteamUser")
self.player_interface = steam.api.interface("IPlayerService")
if not self.game_icons:
for _id in _ids:
res = self.player_interface.GetOwnedGames(
steamid=_id, include_appinfo=1
)["response"]
self.game_icons = self.game_icons | {
game["appid"]: game["img_icon_url"] for game in res.get("games", [])
}
response = self.user_interface.GetPlayerSummaries(steamids=_ids)
players = {
player["steamid"]: player
player["steamid"]: PlayerData(
**player,
level=self.player_interface.GetSteamLevel(steamid=player["steamid"])[
"response"
].get("player_level"),
)
for player in response["response"]["players"]["player"]
if player["steamid"] in _ids
}
for value in players.values():
data = self.player_interface.GetSteamLevel(steamid=value["steamid"])
value["level"] = data["response"].get("player_level")
for player in players.values():
if player.gameid and player.gameid not in self.game_icons:
games = self.player_interface.GetOwnedGames(
steamid=player.steamid,
include_appinfo=1,
include_played_free_games=True,
)["response"].get("games", [])
self.game_icons.update(
{str(game["appid"]): game["img_icon_url"] for game in games}
)
return players
@override
async def _async_update_data(self) -> dict[str, dict[str, str | int]]:
async def _async_update_data(self) -> dict[str, PlayerData]:
"""Send request to the executor."""
try:
return await self.hass.async_add_executor_job(self._update)
except steam.api.HTTPTimeoutError as ex:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from ex
except steam.api.HTTPError as ex:
if "401" in str(ex):
raise ConfigEntryAuthFailed from ex
raise UpdateFailed(ex) from ex
_LOGGER.debug("Full exception:", exc_info=True)
if "401" in str(ex) or "403" in str(ex):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_exception",
) from ex
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="request_exception",
) from ex
+16 -18
View File
@@ -4,7 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from typing import Any, cast, override
from typing import Any, override
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.core import HomeAssistant
@@ -20,7 +20,7 @@ from .const import (
STEAM_MAIN_IMAGE_FILE,
STEAM_STATUSES,
)
from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator
from .coordinator import PlayerData, SteamConfigEntry, SteamDataUpdateCoordinator
from .entity import SteamEntity
PARALLEL_UPDATES = 1
@@ -36,18 +36,18 @@ class SteamSensor(StrEnum):
class SteamSensorEntityDescription(SensorEntityDescription):
"""Steam sensor description."""
value_fn: Callable[[dict[str, Any]], StateType]
name_fn: Callable[[dict[str, Any]], str]
entity_picture_fn: Callable[[dict[str, Any]], str] | None = None
value_fn: Callable[[PlayerData], StateType]
name_fn: Callable[[PlayerData], str]
entity_picture_fn: Callable[[PlayerData], str] | None = None
SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = (
SteamSensorEntityDescription(
key=SteamSensor.ACCOUNT,
translation_key=SteamSensor.ACCOUNT,
value_fn=lambda x: STEAM_STATUSES[x["personastate"]],
name_fn=lambda x: x["personaname"],
entity_picture_fn=lambda x: x["avatarfull"],
value_fn=lambda x: STEAM_STATUSES[x.personastate],
name_fn=lambda x: x.personaname,
entity_picture_fn=lambda x: x.avatarfull,
),
)
@@ -106,29 +106,27 @@ class SteamSensorEntity(SteamEntity, SensorEntity):
player = self.coordinator.data[self._steamid]
attrs: dict[str, str | int | datetime] = {}
if game := player.get("gameextrainfo"):
if game := player.gameextrainfo:
attrs["game"] = game
if game_id := player.get("gameid"):
if game_id := player.gameid:
attrs["game_id"] = game_id
game_url = f"{STEAM_API_URL}{player['gameid']}/"
game_url = f"{STEAM_API_URL}{player.gameid}/"
attrs["game_image_header"] = f"{game_url}{STEAM_HEADER_IMAGE_FILE}"
attrs["game_image_main"] = f"{game_url}{STEAM_MAIN_IMAGE_FILE}"
if info := self._get_game_icon(player):
attrs["game_icon"] = f"{STEAM_ICON_URL}{game_id}/{info}.jpg"
if last_online := cast(int | None, player.get("lastlogoff")):
if last_online := player.lastlogoff:
attrs["last_online"] = dt_util.as_local(
dt_util.utc_from_timestamp(last_online)
)
if level := self.coordinator.data[self._steamid]["level"]:
if level := self.coordinator.data[self._steamid].level:
attrs["level"] = level
return attrs
def _get_game_icon(self, player: dict) -> str | None:
def _get_game_icon(self, player: PlayerData) -> str | None:
"""Get game icon identifier."""
if player.get("gameid") in self.coordinator.game_icons:
return self.coordinator.game_icons[player["gameid"]]
# Reset game icons to have coordinator get id for new game
self.coordinator.game_icons = {}
if player.gameid is not None and player.gameid in self.coordinator.game_icons:
return self.coordinator.game_icons[player.gameid]
return None
@property
@@ -70,6 +70,17 @@
}
}
},
"exceptions": {
"auth_exception": {
"message": "Failed to connect to Steam due to an authentication error"
},
"request_exception": {
"message": "Failed to connect to Steam due to a request error"
},
"timeout_exception": {
"message": "Failed to connect to Steam due to a request timeout"
}
},
"options": {
"error": {
"unauthorized": "Friends list restricted: Please refer to the documentation on how to see all other friends"
@@ -96,7 +96,12 @@ PLATFORMS_BY_TYPE = {
],
SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.STANDING_FAN.value: [Platform.SWITCH, Platform.SENSOR],
SupportedModels.STANDING_FAN.value: [
Platform.SELECT,
Platform.NUMBER,
Platform.SWITCH,
Platform.SENSOR,
],
SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.S20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
+1 -1
View File
@@ -58,7 +58,7 @@ class SwitchbotAirPurifierLightEntity(SwitchbotEntity, LightEntity, RestoreEntit
def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.base_unique_id}_light"
self._attr_unique_id = f"{coordinator.base_unique_id}_light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def async_added_to_hass(self) -> None:
+72 -1
View File
@@ -1,5 +1,7 @@
"""Number platform for SwitchBot devices."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import override
@@ -8,7 +10,11 @@ import switchbot
from switchbot import SwitchbotOperationError
from switchbot.devices.meter_pro import MAX_TIME_OFFSET
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -35,6 +41,11 @@ async def async_setup_entry(
async_add_entities(
[SwitchBotMeterProCO2DisplayTimeOffsetNumber(coordinator)], True
)
elif isinstance(coordinator.device, switchbot.SwitchbotStandingFan):
async_add_entities(
SwitchBotStandingFanOscillationAngleNumber(coordinator, desc)
for desc in OSCILLATION_NUMBER_DESCRIPTIONS
)
class SwitchBotMeterProCO2DisplayTimeOffsetNumber(SwitchbotEntity, NumberEntity):
@@ -78,3 +89,63 @@ class SwitchBotMeterProCO2DisplayTimeOffsetNumber(SwitchbotEntity, NumberEntity)
)
return
self._attr_native_value = round(offset_seconds / _SECONDS_IN_MINUTE)
@dataclass(frozen=True, kw_only=True)
class SwitchBotOscillationAngleNumberEntityDescription(NumberEntityDescription):
"""Describes a Standing Fan oscillation angle number entity."""
setter: Callable[[switchbot.SwitchbotStandingFan, int], Awaitable[None]]
OSCILLATION_NUMBER_DESCRIPTIONS: tuple[
SwitchBotOscillationAngleNumberEntityDescription, ...
] = (
SwitchBotOscillationAngleNumberEntityDescription(
key="horizontal_oscillation_angle",
translation_key="horizontal_oscillation_angle",
native_min_value=30,
native_max_value=90,
native_step=30,
setter=lambda device, angle: device.set_horizontal_oscillation_angle(angle),
),
SwitchBotOscillationAngleNumberEntityDescription(
key="vertical_oscillation_angle",
translation_key="vertical_oscillation_angle",
native_min_value=30,
native_max_value=90,
native_step=30,
setter=lambda device, angle: device.set_vertical_oscillation_angle(angle),
),
)
class SwitchBotStandingFanOscillationAngleNumber(SwitchbotEntity, NumberEntity):
"""Number entity for oscillation angle on Standing Fan.
Uses assumed_state=True because the device does not report its current
oscillation angle back to HA state is only known after the user sets it.
"""
entity_description: SwitchBotOscillationAngleNumberEntityDescription
_device: switchbot.SwitchbotStandingFan
_attr_assumed_state = True
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self,
coordinator: SwitchbotDataUpdateCoordinator,
description: SwitchBotOscillationAngleNumberEntityDescription,
) -> None:
"""Initialize the oscillation angle number entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.base_unique_id}_{description.key}"
@exception_handler
@override
async def async_set_native_value(self, value: float) -> None:
"""Set oscillation angle."""
await self.entity_description.setter(self._device, int(value))
self._attr_native_value = value
self.async_write_ha_state()
+47 -1
View File
@@ -5,7 +5,7 @@ import logging
from typing import override
import switchbot
from switchbot import SwitchbotOperationError
from switchbot import NightLightState, SwitchbotOperationError
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
@@ -34,6 +34,8 @@ async def async_setup_entry(
if isinstance(coordinator.device, switchbot.SwitchbotMeterProCO2):
async_add_entities([SwitchBotMeterProCO2TimeFormatSelect(coordinator)], True)
elif isinstance(coordinator.device, switchbot.SwitchbotStandingFan):
async_add_entities([SwitchBotStandingFanNightLightSelect(coordinator)])
class SwitchBotMeterProCO2TimeFormatSelect(SwitchbotEntity, SelectEntity):
@@ -74,3 +76,47 @@ class SwitchBotMeterProCO2TimeFormatSelect(SwitchbotEntity, SelectEntity):
self._attr_current_option = (
TIME_FORMAT_12H if device_time["12h_mode"] else TIME_FORMAT_24H
)
NIGHT_LIGHT_OFF = "off"
NIGHT_LIGHT_LEVEL_1 = "level_1"
NIGHT_LIGHT_LEVEL_2 = "level_2"
NIGHT_LIGHT_OPTIONS = [NIGHT_LIGHT_OFF, NIGHT_LIGHT_LEVEL_1, NIGHT_LIGHT_LEVEL_2]
NIGHT_LIGHT_TO_STATE: dict[str, NightLightState] = {
NIGHT_LIGHT_OFF: NightLightState.OFF,
NIGHT_LIGHT_LEVEL_1: NightLightState.LEVEL_1,
NIGHT_LIGHT_LEVEL_2: NightLightState.LEVEL_2,
}
NIGHT_LIGHT_FROM_STATE: dict[int, str] = {
state.value: option for option, state in NIGHT_LIGHT_TO_STATE.items()
}
class SwitchBotStandingFanNightLightSelect(SwitchbotEntity, SelectEntity):
"""Select entity for night light on Standing Fan."""
_device: switchbot.SwitchbotStandingFan
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "night_light"
_attr_options = NIGHT_LIGHT_OPTIONS
def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
"""Initialize the select entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.base_unique_id}_night_light"
@property
@override
def current_option(self) -> str | None:
"""Return current night light state."""
state = self._device.get_night_light_state()
if state is None:
return None
return NIGHT_LIGHT_FROM_STATE.get(state)
@exception_handler
@override
async def async_select_option(self, option: str) -> None:
"""Set night light state."""
await self._device.set_night_light(NIGHT_LIGHT_TO_STATE[option])
self.async_write_ha_state()
@@ -287,9 +287,23 @@
"number": {
"display_time_offset": {
"name": "Display time offset"
},
"horizontal_oscillation_angle": {
"name": "Horizontal oscillation angle"
},
"vertical_oscillation_angle": {
"name": "Vertical oscillation angle"
}
},
"select": {
"night_light": {
"name": "Night light",
"state": {
"level_1": "Level 1",
"level_2": "Level 2",
"off": "[%key:common::state::off%]"
}
},
"time_format": {
"name": "Time format",
"state": {
@@ -71,6 +71,6 @@ rules:
stale-devices: todo
# Platinum
async-dependency: todo
async-dependency: done
inject-websession: done
strict-typing: done
@@ -126,7 +126,6 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
DOMAIN,
self.name,
self.logger.log,
start_event is not None,
)
async def _handle_triggered_with_script(
+1 -1
View File
@@ -63,7 +63,7 @@ class SaunaClimate(ToloSaunaCoordinatorEntity, ClimateEntity):
"""Initialize TOLO Sauna Climate entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_climate"
self._attr_unique_id = f"{entry.entry_id}_climate" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override

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