mirror of
https://github.com/home-assistant/core.git
synced 2026-05-27 19:25:18 +02:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e457600f1 | |||
| 51d1d4aa9e | |||
| 8184b93151 | |||
| 403cb85bc8 | |||
| 4bf3a5b4bd | |||
| 5a73d78c90 | |||
| ebd9934213 | |||
| 73898c29e2 | |||
| 3372bf45ec | |||
| 9744388a4e | |||
| 75c52a382e | |||
| f8a65a7c6f | |||
| b2d934fae1 | |||
| eb72a72182 | |||
| a4b9de867c | |||
| 3a4e697414 | |||
| 00010a7508 | |||
| c5e4e97fa9 | |||
| 3f6e323b48 | |||
| b9639ec9f6 | |||
| 31bce13d16 | |||
| 3523a26abd | |||
| a6fcc9f3ff | |||
| efe0000fbe | |||
| 98a7cc66ef | |||
| 7feaf71b9e | |||
| 00a0fae7bc | |||
| 0c816c22e0 | |||
| 42f277716d | |||
| 6669b0de25 | |||
| 50fca42624 | |||
| deecb4ee9c | |||
| 762f07f450 | |||
| e02ea041b7 | |||
| 7912afb765 | |||
| 7adaa09333 | |||
| c5e7ed9aba | |||
| 68b8667998 | |||
| f643dd98e5 | |||
| dcec29dbbf | |||
| 1daff77591 | |||
| 7e3fc18c8c | |||
| b6cc5499aa | |||
| 11920b82fe | |||
| 2649504dfb | |||
| 0a7293dbbd | |||
| 057788d531 | |||
| 74cb4e2448 | |||
| 62aa79a304 | |||
| da74ae1955 | |||
| 2a4728463b | |||
| 3c5bcad0e9 | |||
| 2388353bd2 | |||
| 98823d6816 | |||
| cdd09f2535 | |||
| 2c900c59eb | |||
| 68757996de | |||
| 0fa3985b1d | |||
| a2551647b8 | |||
| e19601f991 | |||
| bc6060f98b | |||
| 0e2190fb25 | |||
| dd75a39e25 | |||
| 6efb3fffa3 | |||
| 4ef409f3cd | |||
| 0842c1cdfc | |||
| 49c045236c | |||
| 0b687df9f8 | |||
| ffcab49087 | |||
| 06c92cd328 | |||
| 66d4124439 | |||
| 99877d79e3 | |||
| 978171b600 | |||
| 4bd011702e | |||
| 64bc689bcf |
@@ -0,0 +1,52 @@
|
|||||||
|
name: Cache and install APT packages
|
||||||
|
description: >-
|
||||||
|
Wraps awalsh128/cache-apt-pkgs-action with the workarounds Home Assistant CI
|
||||||
|
needs. Removes the conflicting Microsoft apt source before any apt run, and
|
||||||
|
points the dynamic linker at the host's multiarch lib subdirectories so
|
||||||
|
shared libraries that rely on update-alternatives or postinst-managed paths
|
||||||
|
(eg libblas, liblapack pulled in by ffmpeg) stay reachable since the upstream
|
||||||
|
action does not execute postinst scripts on cache restore.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
packages:
|
||||||
|
description: Space-delimited list of apt packages to install.
|
||||||
|
required: true
|
||||||
|
version:
|
||||||
|
description: Cache version. Bump to invalidate the cache.
|
||||||
|
required: false
|
||||||
|
default: "1"
|
||||||
|
execute_install_scripts:
|
||||||
|
description: >-
|
||||||
|
Pass-through to awalsh128/cache-apt-pkgs-action. Postinst scripts are not
|
||||||
|
actually cached by the upstream action, so this is largely a no-op today.
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Remove conflicting Microsoft apt source
|
||||||
|
shell: bash
|
||||||
|
run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
|
||||||
|
- name: Install apt packages via cache
|
||||||
|
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
|
||||||
|
with:
|
||||||
|
packages: ${{ inputs.packages }}
|
||||||
|
version: ${{ inputs.version }}
|
||||||
|
execute_install_scripts: ${{ inputs.execute_install_scripts }}
|
||||||
|
- name: Refresh dynamic linker cache
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# awalsh128/cache-apt-pkgs-action does not run postinst scripts on
|
||||||
|
# cache restore, so update-alternatives symlinks (eg the one libblas
|
||||||
|
# creates at /usr/lib/<multiarch>/libblas.so.3) are never produced.
|
||||||
|
# Add every /usr/lib/<multiarch> subdirectory that holds shared
|
||||||
|
# libraries to the ldconfig search path so the dynamic linker still
|
||||||
|
# finds them. Use dpkg-architecture to derive the host's multiarch
|
||||||
|
# tuple so this works on non-x86_64 runners too.
|
||||||
|
multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
|
||||||
|
find "/usr/lib/${multiarch}" -mindepth 2 -maxdepth 2 \
|
||||||
|
-name '*.so.*' -printf '%h\n' \
|
||||||
|
| sort -u \
|
||||||
|
| sudo tee /etc/ld.so.conf.d/zzz-cache-apt-extras.conf > /dev/null
|
||||||
|
sudo ldconfig
|
||||||
@@ -43,6 +43,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
|||||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
||||||
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
||||||
|
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
|
||||||
|
|
||||||
## Good practices
|
## Good practices
|
||||||
|
|
||||||
|
|||||||
+97
-204
@@ -60,9 +60,7 @@ env:
|
|||||||
# - 15.2 is the latest (as of 9 Feb 2023)
|
# - 15.2 is the latest (as of 9 Feb 2023)
|
||||||
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
|
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
|
||||||
UV_CACHE_DIR: /tmp/uv-cache
|
UV_CACHE_DIR: /tmp/uv-cache
|
||||||
APT_CACHE_BASE: /home/runner/work/apt
|
APT_CACHE_VERSION: 1
|
||||||
APT_CACHE_DIR: /home/runner/work/apt/cache
|
|
||||||
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
|
|
||||||
SQLALCHEMY_WARN_20: 1
|
SQLALCHEMY_WARN_20: 1
|
||||||
PYTHONASYNCIODEBUG: 1
|
PYTHONASYNCIODEBUG: 1
|
||||||
HASS_CI: 1
|
HASS_CI: 1
|
||||||
@@ -86,7 +84,6 @@ jobs:
|
|||||||
core: ${{ steps.core.outputs.changes }}
|
core: ${{ steps.core.outputs.changes }}
|
||||||
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
|
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
|
||||||
integrations: ${{ steps.integrations.outputs.changes }}
|
integrations: ${{ steps.integrations.outputs.changes }}
|
||||||
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
|
|
||||||
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
|
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
|
||||||
requirements: ${{ steps.core.outputs.requirements }}
|
requirements: ${{ steps.core.outputs.requirements }}
|
||||||
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
|
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
|
||||||
@@ -116,10 +113,6 @@ jobs:
|
|||||||
# Include HA_SHORT_VERSION to force the immediate creation
|
# Include HA_SHORT_VERSION to force the immediate creation
|
||||||
# of a new uv cache entry after a version bump.
|
# of a new uv cache entry after a version bump.
|
||||||
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
|
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
|
||||||
- name: Generate partial apt restore key
|
|
||||||
id: generate_apt_cache_key
|
|
||||||
run: |
|
|
||||||
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
- name: Filter for core changes
|
- name: Filter for core changes
|
||||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||||
id: core
|
id: core
|
||||||
@@ -384,65 +377,36 @@ jobs:
|
|||||||
path: ${{ env.UV_CACHE_DIR }}
|
path: ${{ env.UV_CACHE_DIR }}
|
||||||
key: ${{ steps.generate-uv-key.outputs.full_key }}
|
key: ${{ steps.generate-uv-key.outputs.full_key }}
|
||||||
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
|
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
|
||||||
- name: Check if apt cache exists
|
|
||||||
id: cache-apt-check
|
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
|
|
||||||
path: |
|
|
||||||
${{ env.APT_CACHE_DIR }}
|
|
||||||
${{ env.APT_LIST_CACHE_DIR }}
|
|
||||||
key: >-
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
|
||||||
- name: Install additional OS dependencies
|
- name: Install additional OS dependencies
|
||||||
if: |
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
steps.cache-venv.outputs.cache-hit != 'true'
|
|
||||||
|| steps.cache-apt-check.outputs.cache-hit != 'true'
|
|
||||||
id: install-os-deps
|
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
env:
|
uses: ./.github/actions/cache-apt-packages
|
||||||
APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
|
|
||||||
run: |
|
|
||||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
|
||||||
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
|
|
||||||
mkdir -p ${APT_CACHE_DIR}
|
|
||||||
mkdir -p ${APT_LIST_CACHE_DIR}
|
|
||||||
fi
|
|
||||||
|
|
||||||
sudo apt-get update \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
|
||||||
sudo apt-get -y install \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
|
||||||
bluez \
|
|
||||||
ffmpeg \
|
|
||||||
libturbojpeg \
|
|
||||||
libxml2-utils \
|
|
||||||
libavcodec-dev \
|
|
||||||
libavdevice-dev \
|
|
||||||
libavfilter-dev \
|
|
||||||
libavformat-dev \
|
|
||||||
libavutil-dev \
|
|
||||||
libswresample-dev \
|
|
||||||
libswscale-dev \
|
|
||||||
libudev-dev
|
|
||||||
|
|
||||||
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
|
|
||||||
sudo chmod -R 755 ${APT_CACHE_BASE}
|
|
||||||
fi
|
|
||||||
- name: Save apt cache
|
|
||||||
if: |
|
|
||||||
always()
|
|
||||||
&& steps.cache-apt-check.outputs.cache-hit != 'true'
|
|
||||||
&& steps.install-os-deps.outcome == 'success'
|
|
||||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
with:
|
||||||
path: |
|
packages: >-
|
||||||
${{ env.APT_CACHE_DIR }}
|
bluez
|
||||||
${{ env.APT_LIST_CACHE_DIR }}
|
ffmpeg
|
||||||
key: >-
|
libturbojpeg
|
||||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
|
with:
|
||||||
|
version: ${{ steps.read-uv-version.outputs.version }}
|
||||||
- name: Create Python virtual environment
|
- name: Create Python virtual environment
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
id: create-venv
|
id: create-venv
|
||||||
@@ -450,8 +414,6 @@ jobs:
|
|||||||
python -m venv venv
|
python -m venv venv
|
||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
python --version
|
python --version
|
||||||
pip install "$(grep '^uv' < requirements.txt)"
|
|
||||||
uv pip install -U "pip>=25.2"
|
|
||||||
uv pip install -r requirements.txt
|
uv pip install -r requirements.txt
|
||||||
uv pip install -r requirements_all.txt -r requirements_test.txt
|
uv pip install -r requirements_all.txt -r requirements_test.txt
|
||||||
uv pip install -e . --config-settings editable_mode=compat
|
uv pip install -e . --config-settings editable_mode=compat
|
||||||
@@ -506,30 +468,16 @@ jobs:
|
|||||||
&& github.event.inputs.mypy-only != 'true'
|
&& github.event.inputs.mypy-only != 'true'
|
||||||
&& github.event.inputs.audit-licenses-only != 'true'
|
&& github.event.inputs.audit-licenses-only != 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Restore apt cache
|
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ env.APT_CACHE_DIR }}
|
|
||||||
${{ env.APT_LIST_CACHE_DIR }}
|
|
||||||
fail-on-cache-miss: true
|
|
||||||
key: >-
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
|
||||||
- name: Install additional OS dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
run: |
|
|
||||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
|
||||||
sudo apt-get update \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
|
||||||
sudo apt-get -y install \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
|
||||||
libturbojpeg
|
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Install additional OS dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
uses: ./.github/actions/cache-apt-packages
|
||||||
|
with:
|
||||||
|
packages: libturbojpeg
|
||||||
|
version: ${{ env.APT_CACHE_VERSION }}
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
@@ -876,32 +824,20 @@ jobs:
|
|||||||
- info
|
- info
|
||||||
- base
|
- base
|
||||||
steps:
|
steps:
|
||||||
- name: Restore apt cache
|
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ env.APT_CACHE_DIR }}
|
|
||||||
${{ env.APT_LIST_CACHE_DIR }}
|
|
||||||
fail-on-cache-miss: true
|
|
||||||
key: >-
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
|
||||||
- name: Install additional OS dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
run: |
|
|
||||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
|
||||||
sudo apt-get update \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
|
||||||
sudo apt-get -y install \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
|
||||||
bluez \
|
|
||||||
ffmpeg \
|
|
||||||
libturbojpeg
|
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Install additional OS dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
uses: ./.github/actions/cache-apt-packages
|
||||||
|
with:
|
||||||
|
packages: >-
|
||||||
|
bluez
|
||||||
|
ffmpeg
|
||||||
|
libturbojpeg
|
||||||
|
version: ${{ env.APT_CACHE_VERSION }}
|
||||||
|
execute_install_scripts: true
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
@@ -952,33 +888,21 @@ jobs:
|
|||||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Restore apt cache
|
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ env.APT_CACHE_DIR }}
|
|
||||||
${{ env.APT_LIST_CACHE_DIR }}
|
|
||||||
fail-on-cache-miss: true
|
|
||||||
key: >-
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
|
||||||
- name: Install additional OS dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
run: |
|
|
||||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
|
||||||
sudo apt-get update \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
|
||||||
sudo apt-get -y install \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
|
||||||
bluez \
|
|
||||||
ffmpeg \
|
|
||||||
libturbojpeg \
|
|
||||||
libxml2-utils
|
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Install additional OS dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
uses: ./.github/actions/cache-apt-packages
|
||||||
|
with:
|
||||||
|
packages: >-
|
||||||
|
bluez
|
||||||
|
ffmpeg
|
||||||
|
libturbojpeg
|
||||||
|
libxml2-utils
|
||||||
|
version: ${{ env.APT_CACHE_VERSION }}
|
||||||
|
execute_install_scripts: true
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
@@ -1105,34 +1029,22 @@ jobs:
|
|||||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Restore apt cache
|
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ env.APT_CACHE_DIR }}
|
|
||||||
${{ env.APT_LIST_CACHE_DIR }}
|
|
||||||
fail-on-cache-miss: true
|
|
||||||
key: >-
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
|
||||||
- name: Install additional OS dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
run: |
|
|
||||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
|
||||||
sudo apt-get update \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
|
||||||
sudo apt-get -y install \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
|
||||||
bluez \
|
|
||||||
ffmpeg \
|
|
||||||
libturbojpeg \
|
|
||||||
libmariadb-dev-compat \
|
|
||||||
libxml2-utils
|
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Install additional OS dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
uses: ./.github/actions/cache-apt-packages
|
||||||
|
with:
|
||||||
|
packages: >-
|
||||||
|
bluez
|
||||||
|
ffmpeg
|
||||||
|
libturbojpeg
|
||||||
|
libmariadb-dev-compat
|
||||||
|
libxml2-utils
|
||||||
|
version: ${{ env.APT_CACHE_VERSION }}
|
||||||
|
execute_install_scripts: true
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
@@ -1266,36 +1178,29 @@ jobs:
|
|||||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Restore apt cache
|
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ env.APT_CACHE_DIR }}
|
|
||||||
${{ env.APT_LIST_CACHE_DIR }}
|
|
||||||
fail-on-cache-miss: true
|
|
||||||
key: >-
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
|
||||||
- name: Install additional OS dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
run: |
|
|
||||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
|
||||||
sudo apt-get update \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
|
||||||
sudo apt-get -y install \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
|
||||||
bluez \
|
|
||||||
ffmpeg \
|
|
||||||
libturbojpeg \
|
|
||||||
libxml2-utils
|
|
||||||
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
|
||||||
sudo apt-get -y install \
|
|
||||||
postgresql-server-dev-14
|
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Install additional OS dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
uses: ./.github/actions/cache-apt-packages
|
||||||
|
with:
|
||||||
|
packages: >-
|
||||||
|
bluez
|
||||||
|
ffmpeg
|
||||||
|
libturbojpeg
|
||||||
|
libxml2-utils
|
||||||
|
version: ${{ env.APT_CACHE_VERSION }}
|
||||||
|
execute_install_scripts: true
|
||||||
|
- name: Set up PostgreSQL apt repository
|
||||||
|
run: sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||||
|
- name: Cache PostgreSQL development headers
|
||||||
|
timeout-minutes: 10
|
||||||
|
uses: ./.github/actions/cache-apt-packages
|
||||||
|
with:
|
||||||
|
packages: postgresql-server-dev-14
|
||||||
|
version: ${{ env.APT_CACHE_VERSION }}
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
@@ -1449,33 +1354,21 @@ jobs:
|
|||||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Restore apt cache
|
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ env.APT_CACHE_DIR }}
|
|
||||||
${{ env.APT_LIST_CACHE_DIR }}
|
|
||||||
fail-on-cache-miss: true
|
|
||||||
key: >-
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
|
||||||
- name: Install additional OS dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
run: |
|
|
||||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
|
||||||
sudo apt-get update \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
|
||||||
sudo apt-get -y install \
|
|
||||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
|
||||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
|
||||||
bluez \
|
|
||||||
ffmpeg \
|
|
||||||
libturbojpeg \
|
|
||||||
libxml2-utils
|
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Install additional OS dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
uses: ./.github/actions/cache-apt-packages
|
||||||
|
with:
|
||||||
|
packages: >-
|
||||||
|
bluez
|
||||||
|
ffmpeg
|
||||||
|
libturbojpeg
|
||||||
|
libxml2-utils
|
||||||
|
version: ${{ env.APT_CACHE_VERSION }}
|
||||||
|
execute_install_scripts: true
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
# - No PRs marked as no-stale
|
# - No PRs marked as no-stale
|
||||||
# - No issues (-1)
|
# - No issues (-1)
|
||||||
- name: 60 days stale PRs policy
|
- name: 60 days stale PRs policy
|
||||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
days-before-stale: 60
|
days-before-stale: 60
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
# - No issues marked as no-stale or help-wanted
|
# - No issues marked as no-stale or help-wanted
|
||||||
# - No PRs (-1)
|
# - No PRs (-1)
|
||||||
- name: 90 days stale issues
|
- name: 90 days stale issues
|
||||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ steps.token.outputs.token }}
|
repo-token: ${{ steps.token.outputs.token }}
|
||||||
days-before-stale: 90
|
days-before-stale: 90
|
||||||
@@ -97,7 +97,7 @@ jobs:
|
|||||||
# - No Issues marked as no-stale or help-wanted
|
# - No Issues marked as no-stale or help-wanted
|
||||||
# - No PRs (-1)
|
# - No PRs (-1)
|
||||||
- name: Needs more information stale issues policy
|
- name: Needs more information stale issues policy
|
||||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ steps.token.outputs.token }}
|
repo-token: ${{ steps.token.outputs.token }}
|
||||||
only-labels: "needs-more-information"
|
only-labels: "needs-more-information"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.15.13
|
rev: v0.15.14
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
args:
|
args:
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ homeassistant.components.led_ble.*
|
|||||||
homeassistant.components.lektrico.*
|
homeassistant.components.lektrico.*
|
||||||
homeassistant.components.letpot.*
|
homeassistant.components.letpot.*
|
||||||
homeassistant.components.lg_infrared.*
|
homeassistant.components.lg_infrared.*
|
||||||
|
homeassistant.components.lg_tv_rs232.*
|
||||||
homeassistant.components.libre_hardware_monitor.*
|
homeassistant.components.libre_hardware_monitor.*
|
||||||
homeassistant.components.lidarr.*
|
homeassistant.components.lidarr.*
|
||||||
homeassistant.components.liebherr.*
|
homeassistant.components.liebherr.*
|
||||||
@@ -428,6 +429,7 @@ homeassistant.components.otp.*
|
|||||||
homeassistant.components.ouman_eh_800.*
|
homeassistant.components.ouman_eh_800.*
|
||||||
homeassistant.components.overkiz.*
|
homeassistant.components.overkiz.*
|
||||||
homeassistant.components.overseerr.*
|
homeassistant.components.overseerr.*
|
||||||
|
homeassistant.components.ovhcloud_ai_endpoints.*
|
||||||
homeassistant.components.p1_monitor.*
|
homeassistant.components.p1_monitor.*
|
||||||
homeassistant.components.paj_gps.*
|
homeassistant.components.paj_gps.*
|
||||||
homeassistant.components.panel_custom.*
|
homeassistant.components.panel_custom.*
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
|||||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
||||||
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
||||||
|
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
|
||||||
|
|
||||||
## Good practices
|
## Good practices
|
||||||
|
|
||||||
|
|||||||
Generated
+8
-2
@@ -987,6 +987,8 @@ CLAUDE.md @home-assistant/core
|
|||||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||||
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
||||||
/tests/components/lg_thinq/ @LG-ThinQ-Integration
|
/tests/components/lg_thinq/ @LG-ThinQ-Integration
|
||||||
|
/homeassistant/components/lg_tv_rs232/ @balloob
|
||||||
|
/tests/components/lg_tv_rs232/ @balloob
|
||||||
/homeassistant/components/libre_hardware_monitor/ @Sab44
|
/homeassistant/components/libre_hardware_monitor/ @Sab44
|
||||||
/tests/components/libre_hardware_monitor/ @Sab44
|
/tests/components/libre_hardware_monitor/ @Sab44
|
||||||
/homeassistant/components/lichess/ @aryanhasgithub
|
/homeassistant/components/lichess/ @aryanhasgithub
|
||||||
@@ -1290,6 +1292,8 @@ CLAUDE.md @home-assistant/core
|
|||||||
/tests/components/openhome/ @bazwilliams
|
/tests/components/openhome/ @bazwilliams
|
||||||
/homeassistant/components/openrgb/ @felipecrs
|
/homeassistant/components/openrgb/ @felipecrs
|
||||||
/tests/components/openrgb/ @felipecrs
|
/tests/components/openrgb/ @felipecrs
|
||||||
|
/homeassistant/components/opensensemap/ @AlCalzone
|
||||||
|
/tests/components/opensensemap/ @AlCalzone
|
||||||
/homeassistant/components/opensky/ @joostlek
|
/homeassistant/components/opensky/ @joostlek
|
||||||
/tests/components/opensky/ @joostlek
|
/tests/components/opensky/ @joostlek
|
||||||
/homeassistant/components/opentherm_gw/ @mvn23
|
/homeassistant/components/opentherm_gw/ @mvn23
|
||||||
@@ -1317,6 +1321,8 @@ CLAUDE.md @home-assistant/core
|
|||||||
/tests/components/overkiz/ @imicknl
|
/tests/components/overkiz/ @imicknl
|
||||||
/homeassistant/components/overseerr/ @joostlek @AmGarera
|
/homeassistant/components/overseerr/ @joostlek @AmGarera
|
||||||
/tests/components/overseerr/ @joostlek @AmGarera
|
/tests/components/overseerr/ @joostlek @AmGarera
|
||||||
|
/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon
|
||||||
|
/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon
|
||||||
/homeassistant/components/ovo_energy/ @timmo001
|
/homeassistant/components/ovo_energy/ @timmo001
|
||||||
/tests/components/ovo_energy/ @timmo001
|
/tests/components/ovo_energy/ @timmo001
|
||||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||||
@@ -2048,8 +2054,8 @@ CLAUDE.md @home-assistant/core
|
|||||||
/tests/components/yamaha_musiccast/ @vigonotion @micha91
|
/tests/components/yamaha_musiccast/ @vigonotion @micha91
|
||||||
/homeassistant/components/yandex_transport/ @rishatik92 @devbis
|
/homeassistant/components/yandex_transport/ @rishatik92 @devbis
|
||||||
/tests/components/yandex_transport/ @rishatik92 @devbis
|
/tests/components/yandex_transport/ @rishatik92 @devbis
|
||||||
/homeassistant/components/yardian/ @h3l1o5
|
/homeassistant/components/yardian/ @aeon-matrix
|
||||||
/tests/components/yardian/ @h3l1o5
|
/tests/components/yardian/ @aeon-matrix
|
||||||
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
||||||
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
||||||
/homeassistant/components/yeelightsunflower/ @lindsaymarkward
|
/homeassistant/components/yeelightsunflower/ @lindsaymarkward
|
||||||
|
|||||||
@@ -92,8 +92,7 @@ def _extract_backup(
|
|||||||
):
|
):
|
||||||
ostf.tar.extractall(
|
ostf.tar.extractall(
|
||||||
path=Path(tempdir, "extracted"),
|
path=Path(tempdir, "extracted"),
|
||||||
members=securetar.secure_path(ostf.tar),
|
filter="tar",
|
||||||
filter="fully_trusted",
|
|
||||||
)
|
)
|
||||||
backup_meta_file = Path(tempdir, "extracted", "backup.json")
|
backup_meta_file = Path(tempdir, "extracted", "backup.json")
|
||||||
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
|
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
|
||||||
@@ -119,8 +118,7 @@ def _extract_backup(
|
|||||||
) as istf:
|
) as istf:
|
||||||
istf.extractall(
|
istf.extractall(
|
||||||
path=Path(tempdir, "homeassistant"),
|
path=Path(tempdir, "homeassistant"),
|
||||||
members=securetar.secure_path(istf),
|
filter="tar",
|
||||||
filter="fully_trusted",
|
|
||||||
)
|
)
|
||||||
if restore_content.restore_homeassistant:
|
if restore_content.restore_homeassistant:
|
||||||
keep = list(KEEP_BACKUPS)
|
keep = list(KEEP_BACKUPS)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from homeassistant.components.select import (
|
|||||||
PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA,
|
PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA,
|
||||||
SelectEntity,
|
SelectEntity,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.const import CONF_NAME, CONF_OPTIONS
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
@@ -19,9 +19,6 @@ from .hub import AdsHub
|
|||||||
|
|
||||||
DEFAULT_NAME = "ADS select"
|
DEFAULT_NAME = "ADS select"
|
||||||
|
|
||||||
# pylint: disable-next=home-assistant-duplicate-const
|
|
||||||
CONF_OPTIONS = "options"
|
|
||||||
|
|
||||||
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
|
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_ADS_VAR): cv.string,
|
vol.Required(CONF_ADS_VAR): cv.string,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.trigger_common_fields:
|
.trigger_common_fields:
|
||||||
behavior: &trigger_behavior
|
behavior: &trigger_behavior
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
fields: &trigger_common_fields
|
fields: &trigger_common_fields
|
||||||
behavior:
|
behavior:
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ PLATFORMS = [
|
|||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
Platform.BUTTON,
|
Platform.BUTTON,
|
||||||
Platform.EVENT,
|
Platform.EVENT,
|
||||||
|
Platform.MEDIA_PLAYER,
|
||||||
Platform.NOTIFY,
|
Platform.NOTIFY,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
@@ -40,6 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
|||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
await coordinator.sync_history_state()
|
await coordinator.sync_history_state()
|
||||||
|
await coordinator.sync_media_state()
|
||||||
|
|
||||||
async def _on_http2_reauth_required() -> None:
|
async def _on_http2_reauth_required() -> None:
|
||||||
entry.async_start_reauth(hass)
|
entry.async_start_reauth(hass)
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ from aioamazondevices.exceptions import (
|
|||||||
CannotConnect,
|
CannotConnect,
|
||||||
CannotRetrieveData,
|
CannotRetrieveData,
|
||||||
)
|
)
|
||||||
from aioamazondevices.structures import AmazonDevice, AmazonVocalRecord
|
from aioamazondevices.structures import (
|
||||||
|
AmazonDevice,
|
||||||
|
AmazonMediaState,
|
||||||
|
AmazonVocalRecord,
|
||||||
|
AmazonVolumeState,
|
||||||
|
)
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@@ -74,10 +79,17 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
self._vocal_records: dict[str, AmazonVocalRecord] = {}
|
self._vocal_records: dict[str, AmazonVocalRecord] = {}
|
||||||
|
|
||||||
self.api.on_history_event.append(self.history_state_event_handler)
|
self.api.on_history_event.append(self.history_state_event_handler)
|
||||||
self.api.on_history_event.freeze()
|
self.api.on_history_event.freeze()
|
||||||
|
|
||||||
|
self._volume_states: dict[str, AmazonVolumeState] = {}
|
||||||
|
self.api.on_volume_state_event.append(self.volume_state_event_handler)
|
||||||
|
self.api.on_volume_state_event.freeze()
|
||||||
|
|
||||||
|
self._media_states: dict[str, AmazonMediaState] = {}
|
||||||
|
self.api.on_media_state_event.append(self.media_state_event_handler)
|
||||||
|
self.api.on_media_state_event.freeze()
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, AmazonDevice]:
|
async def _async_update_data(self) -> dict[str, AmazonDevice]:
|
||||||
"""Update device data."""
|
"""Update device data."""
|
||||||
try:
|
try:
|
||||||
@@ -189,3 +201,31 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
|||||||
def vocal_records(self) -> dict[str, AmazonVocalRecord]:
|
def vocal_records(self) -> dict[str, AmazonVocalRecord]:
|
||||||
"""Vocal records of devices."""
|
"""Vocal records of devices."""
|
||||||
return self._vocal_records
|
return self._vocal_records
|
||||||
|
|
||||||
|
async def sync_media_state(self) -> None:
|
||||||
|
"""Sync media state."""
|
||||||
|
await self.api.sync_media_state()
|
||||||
|
|
||||||
|
async def media_state_event_handler(
|
||||||
|
self, media_state: dict[str, AmazonMediaState]
|
||||||
|
) -> None:
|
||||||
|
"""Handle pushed media state changed events."""
|
||||||
|
self._media_states = media_state
|
||||||
|
self.async_update_listeners()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_states(self) -> dict[str, AmazonMediaState]:
|
||||||
|
"""Media state of devices."""
|
||||||
|
return self._media_states
|
||||||
|
|
||||||
|
async def volume_state_event_handler(
|
||||||
|
self, volume_states: dict[str, AmazonVolumeState]
|
||||||
|
) -> None:
|
||||||
|
"""Handle pushed volume change events."""
|
||||||
|
self._volume_states = volume_states
|
||||||
|
self.async_update_listeners()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume_states(self) -> dict[str, AmazonVolumeState]:
|
||||||
|
"""Volumes of devices."""
|
||||||
|
return self._volume_states
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"""Media player platform for Alexa Devices."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
from aioamazondevices.structures import (
|
||||||
|
AmazonMediaControls,
|
||||||
|
AmazonMediaState,
|
||||||
|
AmazonVolumeState,
|
||||||
|
)
|
||||||
|
|
||||||
|
from homeassistant.components.media_player import (
|
||||||
|
MediaPlayerDeviceClass,
|
||||||
|
MediaPlayerEnqueue,
|
||||||
|
MediaPlayerEntity,
|
||||||
|
MediaPlayerEntityDescription,
|
||||||
|
MediaPlayerEntityFeature,
|
||||||
|
MediaPlayerState,
|
||||||
|
MediaType,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from .const import _LOGGER
|
||||||
|
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||||
|
from .entity import AmazonEntity
|
||||||
|
from .utils import alexa_api_call
|
||||||
|
|
||||||
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
|
STANDARD_SUPPORTED_FEATURES = (
|
||||||
|
MediaPlayerEntityFeature.VOLUME_SET
|
||||||
|
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||||
|
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||||
|
| MediaPlayerEntityFeature.STOP
|
||||||
|
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
|
||||||
|
"""Describes an Alexa Devices media player entity."""
|
||||||
|
|
||||||
|
|
||||||
|
MEDIA_PLAYERS: Final = (
|
||||||
|
AmazonDevicesMediaPlayerEntityDescription(
|
||||||
|
key="media",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: AmazonConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Alexa Devices media player entities from a config entry."""
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
|
known_devices: set[str] = set()
|
||||||
|
|
||||||
|
def _check_device() -> None:
|
||||||
|
"""Add entities for newly discovered devices."""
|
||||||
|
new_entities: list[AlexaDevicesMediaPlayer] = []
|
||||||
|
|
||||||
|
for serial_num, device in coordinator.data.items():
|
||||||
|
if serial_num in known_devices or not device.media_player_supported:
|
||||||
|
continue
|
||||||
|
|
||||||
|
known_devices.add(serial_num)
|
||||||
|
new_entities.extend(
|
||||||
|
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
|
||||||
|
for description in MEDIA_PLAYERS
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_entities:
|
||||||
|
async_add_entities(new_entities)
|
||||||
|
|
||||||
|
remove_listener = coordinator.async_add_listener(_check_device)
|
||||||
|
entry.async_on_unload(remove_listener)
|
||||||
|
_check_device()
|
||||||
|
|
||||||
|
|
||||||
|
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||||
|
"""Representation of an Alexa device media player."""
|
||||||
|
|
||||||
|
entity_description: AmazonDevicesMediaPlayerEntityDescription
|
||||||
|
|
||||||
|
_attr_name = None # Uses the device name
|
||||||
|
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||||
|
_attr_volume_step = 0.05
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: AmazonDevicesCoordinator,
|
||||||
|
serial_num: str,
|
||||||
|
description: AmazonDevicesMediaPlayerEntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize."""
|
||||||
|
self._prev_volume: int | None = None
|
||||||
|
super().__init__(coordinator, serial_num, description)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_state(self) -> AmazonMediaState | None:
|
||||||
|
"""Return the media state relating to device."""
|
||||||
|
if not self.coordinator or not self.coordinator.media_states:
|
||||||
|
return None
|
||||||
|
return self.coordinator.media_states.get(self._serial_num)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume_state(self) -> AmazonVolumeState | None:
|
||||||
|
"""Volume settings for device."""
|
||||||
|
if not self.coordinator or not self.coordinator.volume_states:
|
||||||
|
return None
|
||||||
|
return self.coordinator.volume_states.get(self._serial_num)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||||
|
"""Return dynamically supported features based on current media."""
|
||||||
|
features = STANDARD_SUPPORTED_FEATURES
|
||||||
|
|
||||||
|
if self.media_state is None:
|
||||||
|
return features
|
||||||
|
|
||||||
|
if self.media_state.pause_enabled:
|
||||||
|
features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE
|
||||||
|
|
||||||
|
if self.media_state.next_enabled:
|
||||||
|
features |= MediaPlayerEntityFeature.NEXT_TRACK
|
||||||
|
|
||||||
|
if self.media_state.previous_enabled:
|
||||||
|
features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||||
|
|
||||||
|
return features
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> MediaPlayerState | None:
|
||||||
|
"""Return the current state of the player."""
|
||||||
|
if not self.media_state:
|
||||||
|
return MediaPlayerState.IDLE
|
||||||
|
if self.media_state.player_state == "PLAYING":
|
||||||
|
return MediaPlayerState.PLAYING
|
||||||
|
if self.media_state.player_state == "PAUSED":
|
||||||
|
return MediaPlayerState.PAUSED
|
||||||
|
|
||||||
|
return MediaPlayerState.IDLE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume_level(self) -> float | None:
|
||||||
|
"""Return the volume level (0.0 to 1.0)."""
|
||||||
|
if not self.volume_state or self.volume_state.volume is None:
|
||||||
|
return None
|
||||||
|
return self.volume_state.volume / 100
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_volume_muted(self) -> bool | None:
|
||||||
|
"""Return True if the volume is muted."""
|
||||||
|
if not self.volume_state:
|
||||||
|
return None
|
||||||
|
return self.volume_state.volume == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_title(self) -> str | None:
|
||||||
|
"""Track title."""
|
||||||
|
if not self.media_state:
|
||||||
|
return None
|
||||||
|
return self.media_state.now_playing_title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_artist(self) -> str | None:
|
||||||
|
"""Artist name."""
|
||||||
|
if not self.media_state:
|
||||||
|
return None
|
||||||
|
return self.media_state.now_playing_line1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_album_name(self) -> str | None:
|
||||||
|
"""Album name."""
|
||||||
|
if not self.media_state:
|
||||||
|
return None
|
||||||
|
return self.media_state.now_playing_line2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_image_url(self) -> str | None:
|
||||||
|
"""Album art URL."""
|
||||||
|
if not self.media_state:
|
||||||
|
return None
|
||||||
|
return self.media_state.now_playing_url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_duration(self) -> int | None:
|
||||||
|
"""Duration in seconds."""
|
||||||
|
if not self.media_state:
|
||||||
|
return None
|
||||||
|
return self.media_state.media_length
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_position(self) -> int | None:
|
||||||
|
"""Current playback position in seconds."""
|
||||||
|
if not self.media_state:
|
||||||
|
return None
|
||||||
|
return self.media_state.media_position
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_position_updated_at(self) -> datetime | None:
|
||||||
|
"""When media_position was last updated — HA uses this to interpolate the progress bar."""
|
||||||
|
if not self.media_state:
|
||||||
|
return None
|
||||||
|
return self.media_state.media_position_updated_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_content_type(self) -> MediaType | None:
|
||||||
|
"""Content type — tells HA what kind of media is playing."""
|
||||||
|
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
|
||||||
|
return MediaType.MUSIC
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def async_play_media(
|
||||||
|
self,
|
||||||
|
media_type: MediaType | str,
|
||||||
|
media_id: str,
|
||||||
|
enqueue: MediaPlayerEnqueue | None = None,
|
||||||
|
announce: bool | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Play a piece of media."""
|
||||||
|
await self.async_call_alexa_music(media_id, media_type)
|
||||||
|
|
||||||
|
@alexa_api_call
|
||||||
|
async def async_call_alexa_music(
|
||||||
|
self, search_phrase: str, provider_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Call alexa music."""
|
||||||
|
await self.coordinator.api.call_alexa_music(
|
||||||
|
self.device, search_phrase, provider_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@alexa_api_call
|
||||||
|
async def async_set_device_volume(self, volume: int) -> None:
|
||||||
|
"""Set the device volume."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Setting volume for %s to %s%%",
|
||||||
|
self.device.serial_number,
|
||||||
|
volume,
|
||||||
|
)
|
||||||
|
await self.coordinator.api.set_device_volume(self.device, volume)
|
||||||
|
|
||||||
|
async def async_set_volume_level(self, volume: float) -> None:
|
||||||
|
"""Set the volume level (0.0 to 1.0)."""
|
||||||
|
device_volume = round(volume * 100)
|
||||||
|
await self.async_set_device_volume(device_volume)
|
||||||
|
|
||||||
|
async def async_mute_volume(self, mute: bool) -> None:
|
||||||
|
"""Mute or un-mute the volume."""
|
||||||
|
# Whilst you can mute a device by asking it there appears to be
|
||||||
|
# no way to do this programmatically so set volume to 0
|
||||||
|
if not self.volume_state or self.volume_state.volume is None:
|
||||||
|
return
|
||||||
|
if mute:
|
||||||
|
self._prev_volume = self.volume_state.volume
|
||||||
|
target_volume = 0
|
||||||
|
else:
|
||||||
|
if self._prev_volume is None:
|
||||||
|
return
|
||||||
|
target_volume = self._prev_volume
|
||||||
|
await self.async_set_volume_level(target_volume / 100)
|
||||||
|
|
||||||
|
@alexa_api_call
|
||||||
|
async def _send_media_command(self, command: AmazonMediaControls) -> None:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Sending media command '%s' to %s", command, self.device.serial_number
|
||||||
|
)
|
||||||
|
await self.coordinator.api.send_media_command(self.device, command)
|
||||||
|
|
||||||
|
async def async_media_stop(self) -> None:
|
||||||
|
"""Send stop command."""
|
||||||
|
await self._send_media_command(AmazonMediaControls.Stop)
|
||||||
|
|
||||||
|
async def async_media_pause(self) -> None:
|
||||||
|
"""Send pause command."""
|
||||||
|
await self._send_media_command(AmazonMediaControls.Pause)
|
||||||
|
|
||||||
|
async def async_media_play(self) -> None:
|
||||||
|
"""Send play command."""
|
||||||
|
await self._send_media_command(AmazonMediaControls.Play)
|
||||||
|
|
||||||
|
async def async_media_next_track(self) -> None:
|
||||||
|
"""Send next track command."""
|
||||||
|
await self._send_media_command(AmazonMediaControls.Next)
|
||||||
|
|
||||||
|
async def async_media_previous_track(self) -> None:
|
||||||
|
"""Send previous track command."""
|
||||||
|
await self._send_media_command(AmazonMediaControls.Previous)
|
||||||
@@ -230,13 +230,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
|
|||||||
|
|
||||||
if entry.version == 2 and entry.minor_version == 3:
|
if entry.version == 2 and entry.minor_version == 3:
|
||||||
# Remove Temperature parameter
|
# Remove Temperature parameter
|
||||||
CONF_TEMPERATURE = "temperature"
|
temperature_key = "temperature"
|
||||||
|
|
||||||
for subentry in entry.subentries.values():
|
for subentry in entry.subentries.values():
|
||||||
data = subentry.data.copy()
|
data = subentry.data.copy()
|
||||||
if CONF_TEMPERATURE not in data:
|
if temperature_key not in data:
|
||||||
continue
|
continue
|
||||||
data.pop(CONF_TEMPERATURE, None)
|
data.pop(temperature_key, None)
|
||||||
hass.config_entries.async_update_subentry(entry, subentry, data=data)
|
hass.config_entries.async_update_subentry(entry, subentry, data=data)
|
||||||
|
|
||||||
hass.config_entries.async_update_entry(entry, minor_version=4)
|
hass.config_entries.async_update_entry(entry, minor_version=4)
|
||||||
|
|||||||
@@ -7,27 +7,3 @@ CONNECTION_TIMEOUT: int = 10
|
|||||||
|
|
||||||
# Field name of last self test retrieved from apcupsd.
|
# Field name of last self test retrieved from apcupsd.
|
||||||
LAST_S_TEST: Final = "laststest"
|
LAST_S_TEST: Final = "laststest"
|
||||||
|
|
||||||
# Mapping of deprecated sensor keys (as reported by apcupsd,
|
|
||||||
# lower-cased) to their deprecation
|
|
||||||
# repair issue translation keys.
|
|
||||||
DEPRECATED_SENSORS: Final = {
|
|
||||||
"apc": "apc_deprecated",
|
|
||||||
"end apc": "date_deprecated",
|
|
||||||
"date": "date_deprecated",
|
|
||||||
"apcmodel": "available_via_device_info",
|
|
||||||
"model": "available_via_device_info",
|
|
||||||
"firmware": "available_via_device_info",
|
|
||||||
"version": "available_via_device_info",
|
|
||||||
"upsname": "available_via_device_info",
|
|
||||||
"serialno": "available_via_device_info",
|
|
||||||
}
|
|
||||||
|
|
||||||
AVAILABLE_VIA_DEVICE_ATTR: Final = {
|
|
||||||
"apcmodel": "model",
|
|
||||||
"model": "model",
|
|
||||||
"firmware": "hw_version",
|
|
||||||
"version": "sw_version",
|
|
||||||
"upsname": "name",
|
|
||||||
"serialno": "serial_number",
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"""Support for APCUPSd sensors."""
|
"""Support for APCUPSd sensors."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
import dateutil
|
import dateutil
|
||||||
|
|
||||||
from homeassistant.components.automation import automations_with_entity
|
|
||||||
from homeassistant.components.script import scripts_with_entity
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
SensorEntity,
|
SensorEntity,
|
||||||
@@ -24,11 +23,9 @@ from homeassistant.const import (
|
|||||||
UnitOfTime,
|
UnitOfTime,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import entity_registry as er
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
import homeassistant.helpers.issue_registry as ir
|
|
||||||
|
|
||||||
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST
|
from .const import LAST_S_TEST
|
||||||
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
|
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
|
||||||
from .entity import APCUPSdEntity
|
from .entity import APCUPSdEntity
|
||||||
|
|
||||||
@@ -36,6 +33,20 @@ PARALLEL_UPDATES = 0
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# List of useless sensors to ignore, since they are either provided in device
|
||||||
|
# information, or not useful at all
|
||||||
|
IGNORED_SENSORS: Final = {
|
||||||
|
"apc",
|
||||||
|
"end apc",
|
||||||
|
"date",
|
||||||
|
"apcmodel",
|
||||||
|
"model",
|
||||||
|
"firmware",
|
||||||
|
"version",
|
||||||
|
"upsname",
|
||||||
|
"serialno",
|
||||||
|
}
|
||||||
|
|
||||||
SENSORS: dict[str, SensorEntityDescription] = {
|
SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
"alarmdel": SensorEntityDescription(
|
"alarmdel": SensorEntityDescription(
|
||||||
key="alarmdel",
|
key="alarmdel",
|
||||||
@@ -49,18 +60,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
),
|
||||||
"apc": SensorEntityDescription(
|
|
||||||
key="apc",
|
|
||||||
translation_key="apc_status",
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
|
||||||
"apcmodel": SensorEntityDescription(
|
|
||||||
key="apcmodel",
|
|
||||||
translation_key="apc_model",
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
|
||||||
"badbatts": SensorEntityDescription(
|
"badbatts": SensorEntityDescription(
|
||||||
key="badbatts",
|
key="badbatts",
|
||||||
translation_key="bad_batteries",
|
translation_key="bad_batteries",
|
||||||
@@ -100,12 +99,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
),
|
),
|
||||||
"date": SensorEntityDescription(
|
|
||||||
key="date",
|
|
||||||
translation_key="date",
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
|
||||||
"dipsw": SensorEntityDescription(
|
"dipsw": SensorEntityDescription(
|
||||||
key="dipsw",
|
key="dipsw",
|
||||||
translation_key="dip_switch_settings",
|
translation_key="dip_switch_settings",
|
||||||
@@ -132,23 +125,11 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
translation_key="wake_delay",
|
translation_key="wake_delay",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
"end apc": SensorEntityDescription(
|
|
||||||
key="end apc",
|
|
||||||
translation_key="date_and_time",
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
|
||||||
"extbatts": SensorEntityDescription(
|
"extbatts": SensorEntityDescription(
|
||||||
key="extbatts",
|
key="extbatts",
|
||||||
translation_key="external_batteries",
|
translation_key="external_batteries",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
"firmware": SensorEntityDescription(
|
|
||||||
key="firmware",
|
|
||||||
translation_key="firmware_version",
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
|
||||||
"hitrans": SensorEntityDescription(
|
"hitrans": SensorEntityDescription(
|
||||||
key="hitrans",
|
key="hitrans",
|
||||||
translation_key="transfer_high",
|
translation_key="transfer_high",
|
||||||
@@ -264,12 +245,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
translation_key="min_time",
|
translation_key="min_time",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
"model": SensorEntityDescription(
|
|
||||||
key="model",
|
|
||||||
translation_key="model",
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
|
||||||
"nombattv": SensorEntityDescription(
|
"nombattv": SensorEntityDescription(
|
||||||
key="nombattv",
|
key="nombattv",
|
||||||
translation_key="battery_nominal_voltage",
|
translation_key="battery_nominal_voltage",
|
||||||
@@ -358,12 +333,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
"serialno": SensorEntityDescription(
|
|
||||||
key="serialno",
|
|
||||||
translation_key="serial_number",
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
|
||||||
"starttime": SensorEntityDescription(
|
"starttime": SensorEntityDescription(
|
||||||
key="starttime",
|
key="starttime",
|
||||||
translation_key="startup_time",
|
translation_key="startup_time",
|
||||||
@@ -404,18 +373,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
translation_key="ups_mode",
|
translation_key="ups_mode",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
"upsname": SensorEntityDescription(
|
|
||||||
key="upsname",
|
|
||||||
translation_key="ups_name",
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
|
||||||
"version": SensorEntityDescription(
|
|
||||||
key="version",
|
|
||||||
translation_key="version",
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
|
||||||
"xoffbat": SensorEntityDescription(
|
"xoffbat": SensorEntityDescription(
|
||||||
key="xoffbat",
|
key="xoffbat",
|
||||||
translation_key="transfer_from_battery",
|
translation_key="transfer_from_battery",
|
||||||
@@ -481,9 +438,10 @@ async def async_setup_entry(
|
|||||||
# as unknown initially.
|
# as unknown initially.
|
||||||
#
|
#
|
||||||
# We also sort the resources to ensure the order of entities
|
# We also sort the resources to ensure the order of entities
|
||||||
# created is deterministic since "APCMODEL" and "MODEL"
|
# created is deterministic
|
||||||
# resources map to the same "Model" name.
|
|
||||||
for resource in sorted(available_resources | {LAST_S_TEST}):
|
for resource in sorted(available_resources | {LAST_S_TEST}):
|
||||||
|
if resource in IGNORED_SENSORS:
|
||||||
|
continue
|
||||||
if resource not in SENSORS:
|
if resource not in SENSORS:
|
||||||
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
||||||
continue
|
continue
|
||||||
@@ -561,63 +519,3 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
|||||||
self._attr_native_value, inferred_unit = infer_unit(data)
|
self._attr_native_value, inferred_unit = infer_unit(data)
|
||||||
if not self.native_unit_of_measurement:
|
if not self.native_unit_of_measurement:
|
||||||
self._attr_native_unit_of_measurement = inferred_unit
|
self._attr_native_unit_of_measurement = inferred_unit
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
|
||||||
"""Handle when entity is added to Home Assistant.
|
|
||||||
|
|
||||||
If this is a deprecated sensor entity, create a repair issue to guide
|
|
||||||
the user to disable it.
|
|
||||||
"""
|
|
||||||
await super().async_added_to_hass()
|
|
||||||
|
|
||||||
if not self.enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
reason = DEPRECATED_SENSORS.get(self.entity_description.key)
|
|
||||||
if not reason:
|
|
||||||
return
|
|
||||||
|
|
||||||
automations = automations_with_entity(self.hass, self.entity_id)
|
|
||||||
scripts = scripts_with_entity(self.hass, self.entity_id)
|
|
||||||
if not automations and not scripts:
|
|
||||||
return
|
|
||||||
|
|
||||||
entity_registry = er.async_get(self.hass)
|
|
||||||
items = [
|
|
||||||
f"- [{entry.name or entry.original_name or entity_id}]"
|
|
||||||
f"(/config/{integration}/edit/"
|
|
||||||
f"{entry.unique_id or entity_id.split('.', 1)[-1]})"
|
|
||||||
for integration, entities in (
|
|
||||||
("automation", automations),
|
|
||||||
("script", scripts),
|
|
||||||
)
|
|
||||||
for entity_id in entities
|
|
||||||
if (entry := entity_registry.async_get(entity_id))
|
|
||||||
]
|
|
||||||
placeholders = {
|
|
||||||
"entity_name": str(self.name or self.entity_id),
|
|
||||||
"entity_id": self.entity_id,
|
|
||||||
"items": "\n".join(items),
|
|
||||||
}
|
|
||||||
if via_attr := AVAILABLE_VIA_DEVICE_ATTR.get(self.entity_description.key):
|
|
||||||
placeholders["available_via_device_attr"] = via_attr
|
|
||||||
if device_entry := self.device_entry:
|
|
||||||
placeholders["device_id"] = device_entry.id
|
|
||||||
|
|
||||||
ir.async_create_issue(
|
|
||||||
self.hass,
|
|
||||||
DOMAIN,
|
|
||||||
f"{reason}_{self.entity_id}",
|
|
||||||
breaks_in_ha_version="2026.6.0",
|
|
||||||
is_fixable=False,
|
|
||||||
severity=ir.IssueSeverity.WARNING,
|
|
||||||
translation_key=reason,
|
|
||||||
translation_placeholders=placeholders,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
|
||||||
"""Handle when entity will be removed from Home Assistant."""
|
|
||||||
await super().async_will_remove_from_hass()
|
|
||||||
|
|
||||||
if issue_key := DEPRECATED_SENSORS.get(self.entity_description.key):
|
|
||||||
ir.async_delete_issue(self.hass, DOMAIN, f"{issue_key}_{self.entity_id}")
|
|
||||||
|
|||||||
@@ -241,19 +241,5 @@
|
|||||||
"cannot_connect": {
|
"cannot_connect": {
|
||||||
"message": "Cannot connect to APC UPS Daemon."
|
"message": "Cannot connect to APC UPS Daemon."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"issues": {
|
|
||||||
"apc_deprecated": {
|
|
||||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because it exposes internal details of the APC UPS Daemon response.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use supported APC UPS entities instead. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
|
||||||
"title": "{entity_name} sensor is deprecated"
|
|
||||||
},
|
|
||||||
"available_via_device_info": {
|
|
||||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the same value is available from the device registry via `device_attr(\"{device_id}\", \"{available_via_device_attr}\")`.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use the `device_attr` helper instead of this sensor. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
|
||||||
"title": "{entity_name} sensor is deprecated"
|
|
||||||
},
|
|
||||||
"date_deprecated": {
|
|
||||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the timestamp is already available from other APC UPS sensors via their last updated time.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to reference any entity's `last_updated` attribute instead (for example, `states.binary_sensor.apcups_online_status.last_updated`). Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
|
||||||
"title": "{entity_name} sensor is deprecated"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
fields:
|
fields:
|
||||||
behavior:
|
behavior:
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -49,6 +49,20 @@ SENSORS_TYPE_COUNT = "sensors_count"
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ENTITY_MIGRATION_ID = {
|
||||||
|
"sensor_connected_device": "Devices Connected",
|
||||||
|
"sensor_rx_bytes": "Download",
|
||||||
|
"sensor_tx_bytes": "Upload",
|
||||||
|
"sensor_rx_rates": "Download Speed",
|
||||||
|
"sensor_tx_rates": "Upload Speed",
|
||||||
|
"sensor_load_avg1": "Load Avg (1m)",
|
||||||
|
"sensor_load_avg5": "Load Avg (5m)",
|
||||||
|
"sensor_load_avg15": "Load Avg (15m)",
|
||||||
|
"2.4GHz": "2.4GHz Temperature",
|
||||||
|
"5.0GHz": "5GHz Temperature",
|
||||||
|
"CPU": "CPU Temperature",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AsusWrtSensorDataHandler:
|
class AsusWrtSensorDataHandler:
|
||||||
"""Data handler for AsusWrt sensor."""
|
"""Data handler for AsusWrt sensor."""
|
||||||
@@ -187,20 +201,6 @@ class AsusWrtRouter:
|
|||||||
|
|
||||||
def _migrate_entities_unique_id(self) -> None:
|
def _migrate_entities_unique_id(self) -> None:
|
||||||
"""Migrate router entities to new unique id format."""
|
"""Migrate router entities to new unique id format."""
|
||||||
_ENTITY_MIGRATION_ID = {
|
|
||||||
"sensor_connected_device": "Devices Connected",
|
|
||||||
"sensor_rx_bytes": "Download",
|
|
||||||
"sensor_tx_bytes": "Upload",
|
|
||||||
"sensor_rx_rates": "Download Speed",
|
|
||||||
"sensor_tx_rates": "Upload Speed",
|
|
||||||
"sensor_load_avg1": "Load Avg (1m)",
|
|
||||||
"sensor_load_avg5": "Load Avg (5m)",
|
|
||||||
"sensor_load_avg15": "Load Avg (15m)",
|
|
||||||
"2.4GHz": "2.4GHz Temperature",
|
|
||||||
"5.0GHz": "5GHz Temperature",
|
|
||||||
"CPU": "CPU Temperature",
|
|
||||||
}
|
|
||||||
|
|
||||||
entity_reg = er.async_get(self.hass)
|
entity_reg = er.async_get(self.hass)
|
||||||
router_entries = er.async_entries_for_config_entry(
|
router_entries = er.async_entries_for_config_entry(
|
||||||
entity_reg, self._entry.entry_id
|
entity_reg, self._entry.entry_id
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
|
|||||||
translation_key="invalid_bucket_name",
|
translation_key="invalid_bucket_name",
|
||||||
) from err
|
) from err
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
|
||||||
raise ConfigEntryError(
|
raise ConfigEntryError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="invalid_endpoint_url",
|
translation_key="invalid_endpoint_url",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
|
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
|
||||||
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
|
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
|
||||||
"invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
|
"invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
|
||||||
"invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
|
"invalid_endpoint_url": "[%key:component::aws_s3::exceptions::invalid_endpoint_url::message%]"
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
@@ -48,6 +48,9 @@
|
|||||||
},
|
},
|
||||||
"invalid_credentials": {
|
"invalid_credentials": {
|
||||||
"message": "Bucket cannot be accessed using provided combination of access key ID and secret access key."
|
"message": "Bucket cannot be accessed using provided combination of access key ID and secret access key."
|
||||||
|
},
|
||||||
|
"invalid_endpoint_url": {
|
||||||
|
"message": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.trigger_common_fields:
|
.trigger_common_fields:
|
||||||
behavior: &trigger_behavior
|
behavior: &trigger_behavior
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"bluetooth-adapters==2.3.0",
|
"bluetooth-adapters==2.3.0",
|
||||||
"bluetooth-auto-recovery==1.6.4",
|
"bluetooth-auto-recovery==1.6.4",
|
||||||
"bluetooth-data-tools==1.29.18",
|
"bluetooth-data-tools==1.29.18",
|
||||||
"dbus-fast==5.0.14",
|
"dbus-fast==5.0.15",
|
||||||
"habluetooth==6.7.4"
|
"habluetooth==6.7.9"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,16 @@ OPTIONS_SCHEMA = KNOWN_HOSTS_SCHEMA.extend(
|
|||||||
vol.Required(CONF_MORE_OPTIONS): section(
|
vol.Required(CONF_MORE_OPTIONS): section(
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(CONF_UUID): str,
|
vol.Optional(CONF_UUID): SelectSelector(
|
||||||
vol.Optional(CONF_IGNORE_CEC): str,
|
SelectSelectorConfig(
|
||||||
|
custom_value=True, options=[], multiple=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
vol.Optional(CONF_IGNORE_CEC): SelectSelector(
|
||||||
|
SelectSelectorConfig(
|
||||||
|
custom_value=True, options=[], multiple=True
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
SectionConfig(collapsed=True),
|
SectionConfig(collapsed=True),
|
||||||
@@ -109,13 +117,11 @@ class CastOptionsFlowHandler(OptionsFlow):
|
|||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Manage the Google Cast options."""
|
"""Manage the Google Cast options."""
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
ignore_cec = _string_to_list(
|
ignore_cec = _trim_items(
|
||||||
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, "")
|
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, [])
|
||||||
)
|
)
|
||||||
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
|
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
|
||||||
wanted_uuid = _string_to_list(
|
wanted_uuid = _trim_items(user_input[CONF_MORE_OPTIONS].get(CONF_UUID, []))
|
||||||
user_input[CONF_MORE_OPTIONS].get(CONF_UUID, "")
|
|
||||||
)
|
|
||||||
updated_config = dict(self.config_entry.data)
|
updated_config = dict(self.config_entry.data)
|
||||||
updated_config[CONF_IGNORE_CEC] = ignore_cec
|
updated_config[CONF_IGNORE_CEC] = ignore_cec
|
||||||
updated_config[CONF_KNOWN_HOSTS] = known_hosts
|
updated_config[CONF_KNOWN_HOSTS] = known_hosts
|
||||||
@@ -132,9 +138,7 @@ class CastOptionsFlowHandler(OptionsFlow):
|
|||||||
for key in (CONF_UUID, CONF_IGNORE_CEC):
|
for key in (CONF_UUID, CONF_IGNORE_CEC):
|
||||||
if key not in self.config_entry.data:
|
if key not in self.config_entry.data:
|
||||||
continue
|
continue
|
||||||
suggested[CONF_MORE_OPTIONS][key] = _list_to_string(
|
suggested[CONF_MORE_OPTIONS][key] = self.config_entry.data[key]
|
||||||
self.config_entry.data[key]
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
@@ -143,16 +147,5 @@ class CastOptionsFlowHandler(OptionsFlow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _list_to_string(items: list[str]) -> str:
|
|
||||||
comma_separated_string = ""
|
|
||||||
if items:
|
|
||||||
comma_separated_string = ",".join(items)
|
|
||||||
return comma_separated_string
|
|
||||||
|
|
||||||
|
|
||||||
def _string_to_list(string: str) -> list[str]:
|
|
||||||
return [x.strip() for x in string.split(",") if x.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def _trim_items(items: list[str]) -> list[str]:
|
def _trim_items(items: list[str]) -> list[str]:
|
||||||
return [x.strip() for x in items if x.strip()]
|
return [x.strip() for x in items if x.strip()]
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Diagnostics for the cert_expiry integration."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.diagnostics import async_redact_data
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .coordinator import CertExpiryConfigEntry
|
||||||
|
|
||||||
|
TO_REDACT = {CONF_HOST, "name", "title", "unique_id"}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_config_entry_diagnostics(
|
||||||
|
_hass: HomeAssistant,
|
||||||
|
entry: CertExpiryConfigEntry,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return diagnostics for a config entry."""
|
||||||
|
entry_diagnostics = entry.as_dict()
|
||||||
|
|
||||||
|
coordinator = getattr(entry, "runtime_data", None)
|
||||||
|
|
||||||
|
coordinator_diagnostics: dict[str, Any] = {
|
||||||
|
"host": None,
|
||||||
|
"port": None,
|
||||||
|
"name": None,
|
||||||
|
"expiry_datetime": None,
|
||||||
|
"is_cert_valid": None,
|
||||||
|
"cert_error": None,
|
||||||
|
"last_update_success": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if coordinator is not None:
|
||||||
|
expiry = coordinator.data.isoformat() if coordinator.data else None
|
||||||
|
cert_error = (
|
||||||
|
(
|
||||||
|
f"{type(coordinator.cert_error).__module__}."
|
||||||
|
f"{type(coordinator.cert_error).__qualname__}"
|
||||||
|
)
|
||||||
|
if coordinator.cert_error
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator_diagnostics = {
|
||||||
|
"host": coordinator.host,
|
||||||
|
"port": coordinator.port,
|
||||||
|
"name": coordinator.name,
|
||||||
|
"expiry_datetime": expiry,
|
||||||
|
"is_cert_valid": coordinator.is_cert_valid,
|
||||||
|
"cert_error": cert_error,
|
||||||
|
"last_update_success": coordinator.last_update_success,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entry": async_redact_data(entry_diagnostics, TO_REDACT),
|
||||||
|
"coordinator": async_redact_data(coordinator_diagnostics, TO_REDACT),
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
rules:
|
||||||
|
# Bronze
|
||||||
|
action-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: Integration does not register custom actions.
|
||||||
|
appropriate-polling:
|
||||||
|
status: done
|
||||||
|
comment: Certificates are checked every 12 hours via DataUpdateCoordinator.
|
||||||
|
brands: done
|
||||||
|
common-modules: done
|
||||||
|
config-flow-test-coverage:
|
||||||
|
status: done
|
||||||
|
comment: test_abort_on_socket_failed can be parametrized and should end in CREATE_ENTRY to test flow recovery.
|
||||||
|
config-flow: done
|
||||||
|
dependency-transparency:
|
||||||
|
status: exempt
|
||||||
|
comment: Integration has no external library dependencies.
|
||||||
|
docs-actions:
|
||||||
|
status: exempt
|
||||||
|
comment: Integration does not register custom actions.
|
||||||
|
docs-high-level-description: done
|
||||||
|
docs-installation-instructions: done
|
||||||
|
docs-removal-instructions: done
|
||||||
|
entity-event-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: Integration does not subscribe to events.
|
||||||
|
entity-unique-id: done
|
||||||
|
has-entity-name: done
|
||||||
|
runtime-data: done
|
||||||
|
test-before-configure: done
|
||||||
|
test-before-setup: todo
|
||||||
|
unique-config-entry: done
|
||||||
|
# Silver
|
||||||
|
action-exceptions:
|
||||||
|
status: exempt
|
||||||
|
comment: Integration does not register custom actions.
|
||||||
|
config-entry-unloading: done
|
||||||
|
docs-configuration-parameters: todo
|
||||||
|
docs-installation-parameters: todo
|
||||||
|
entity-unavailable: done
|
||||||
|
integration-owner: done
|
||||||
|
log-when-unavailable: done
|
||||||
|
parallel-updates: todo
|
||||||
|
reauthentication-flow:
|
||||||
|
status: exempt
|
||||||
|
comment: Config flow only collects host/port; the integration does not authenticate.
|
||||||
|
test-coverage:
|
||||||
|
status: todo
|
||||||
|
comment: Consider creating a mock_config_entry fixture and use that throughout tests.
|
||||||
|
# Gold
|
||||||
|
devices: done
|
||||||
|
diagnostics: todo
|
||||||
|
discovery: todo
|
||||||
|
discovery-update-info: 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:
|
||||||
|
status: exempt
|
||||||
|
comment: Integration supports a single device per config entry.
|
||||||
|
entity-category:
|
||||||
|
status: todo
|
||||||
|
comment: Extra state attributes (is_valid, error) should be moved to separate entities in the future.
|
||||||
|
entity-device-class: done
|
||||||
|
entity-disabled-by-default: todo
|
||||||
|
entity-translations: done
|
||||||
|
exception-translations: todo
|
||||||
|
icon-translations: todo
|
||||||
|
reconfiguration-flow: done
|
||||||
|
repair-issues: todo
|
||||||
|
stale-devices:
|
||||||
|
status: exempt
|
||||||
|
comment: Integration supports a single device per config entry.
|
||||||
|
# Platinum
|
||||||
|
async-dependency: todo
|
||||||
|
inject-websession: todo
|
||||||
|
strict-typing: todo
|
||||||
@@ -16,6 +16,10 @@
|
|||||||
"host": "[%key:common::config_flow::data::host%]",
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
"port": "[%key:common::config_flow::data::port%]"
|
"port": "[%key:common::config_flow::data::port%]"
|
||||||
},
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The hostname or IP address of the server to monitor.",
|
||||||
|
"port": "The port to connect to on the server."
|
||||||
|
},
|
||||||
"title": "Reconfigure the certificate to test"
|
"title": "Reconfigure the certificate to test"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -24,6 +28,10 @@
|
|||||||
"name": "The name of the certificate",
|
"name": "The name of the certificate",
|
||||||
"port": "[%key:common::config_flow::data::port%]"
|
"port": "[%key:common::config_flow::data::port%]"
|
||||||
},
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The hostname or IP address of the server to monitor.",
|
||||||
|
"port": "The port to connect to on the server."
|
||||||
|
},
|
||||||
"title": "Define the certificate to test"
|
"title": "Define the certificate to test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant, State
|
|||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.automation import DomainSpec
|
from homeassistant.helpers.automation import DomainSpec
|
||||||
from homeassistant.helpers.trigger import (
|
from homeassistant.helpers.trigger import (
|
||||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
|
||||||
EntityNumericalStateChangedTriggerBase,
|
EntityNumericalStateChangedTriggerBase,
|
||||||
EntityNumericalStateChangedTriggerWithUnitBase,
|
EntityNumericalStateChangedTriggerWithUnitBase,
|
||||||
EntityNumericalStateCrossedThresholdTriggerBase,
|
EntityNumericalStateCrossedThresholdTriggerBase,
|
||||||
@@ -26,7 +26,7 @@ from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
|||||||
|
|
||||||
CONF_HVAC_MODE = "hvac_mode"
|
CONF_HVAC_MODE = "hvac_mode"
|
||||||
|
|
||||||
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
|
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_OPTIONS): {
|
vol.Required(CONF_OPTIONS): {
|
||||||
vol.Required(CONF_HVAC_MODE): vol.All(
|
vol.Required(CONF_HVAC_MODE): vol.All(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
fields:
|
fields:
|
||||||
behavior: &trigger_behavior
|
behavior: &trigger_behavior
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ class ConfigManagerFlowIndexView(
|
|||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("handler"): vol.Any(str, list),
|
vol.Required("handler"): vol.Any(str, list),
|
||||||
vol.Optional("show_advanced_options", default=False): cv.boolean,
|
|
||||||
vol.Optional("entry_id"): cv.string,
|
vol.Optional("entry_id"): cv.string,
|
||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
@@ -302,7 +301,6 @@ class SubentryManagerFlowIndexView(
|
|||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
|
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
|
||||||
vol.Optional("show_advanced_options", default=False): cv.boolean,
|
|
||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
fields:
|
fields:
|
||||||
behavior:
|
behavior:
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.trigger_common_fields: &trigger_common_fields
|
.trigger_common_fields: &trigger_common_fields
|
||||||
behavior:
|
behavior:
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
|
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"quality_scale": "silver",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["data-grand-lyon-ha==0.7.0"]
|
"requirements": ["data-grand-lyon-ha==0.7.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,13 +49,15 @@ rules:
|
|||||||
status: exempt
|
status: exempt
|
||||||
comment: This is a service integration; there are no discoverable devices.
|
comment: This is a service integration; there are no discoverable devices.
|
||||||
docs-data-update: done
|
docs-data-update: done
|
||||||
docs-examples: todo
|
docs-examples: done
|
||||||
docs-known-limitations: done
|
docs-known-limitations: done
|
||||||
docs-supported-devices: done
|
docs-supported-devices: done
|
||||||
docs-supported-functions: done
|
docs-supported-functions: done
|
||||||
docs-troubleshooting: done
|
docs-troubleshooting: done
|
||||||
docs-use-cases: done
|
docs-use-cases: done
|
||||||
dynamic-devices: done
|
dynamic-devices:
|
||||||
|
status: exempt
|
||||||
|
comment: This is a service integration; devices are added and removed manually by the user.
|
||||||
entity-category: done
|
entity-category: done
|
||||||
entity-device-class: done
|
entity-device-class: done
|
||||||
entity-disabled-by-default: done
|
entity-disabled-by-default: done
|
||||||
@@ -66,7 +68,9 @@ rules:
|
|||||||
repair-issues:
|
repair-issues:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: no known use cases for repair issues or flows, yet
|
comment: no known use cases for repair issues or flows, yet
|
||||||
stale-devices: done
|
stale-devices:
|
||||||
|
status: exempt
|
||||||
|
comment: This is a service integration; devices are added and removed manually by the user.
|
||||||
|
|
||||||
# Platinum
|
# Platinum
|
||||||
async-dependency: done
|
async-dependency: done
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .const import ( # noqa: F401
|
|||||||
ATTR_LOCATION_NAME,
|
ATTR_LOCATION_NAME,
|
||||||
ATTR_MAC,
|
ATTR_MAC,
|
||||||
ATTR_SOURCE_TYPE,
|
ATTR_SOURCE_TYPE,
|
||||||
|
CONF_ASSOCIATED_ZONE,
|
||||||
CONF_CONSIDER_HOME,
|
CONF_CONSIDER_HOME,
|
||||||
CONF_NEW_DEVICE_DEFAULTS,
|
CONF_NEW_DEVICE_DEFAULTS,
|
||||||
CONF_SCAN_INTERVAL,
|
CONF_SCAN_INTERVAL,
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ DEFAULT_CONSIDER_HOME: Final = timedelta(seconds=180)
|
|||||||
|
|
||||||
CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults"
|
CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults"
|
||||||
|
|
||||||
|
CONF_ASSOCIATED_ZONE: Final = "associated_zone"
|
||||||
|
|
||||||
ATTR_ATTRIBUTES: Final = "attributes"
|
ATTR_ATTRIBUTES: Final = "attributes"
|
||||||
ATTR_BATTERY: Final = "battery"
|
ATTR_BATTERY: Final = "battery"
|
||||||
ATTR_DEV_ID: Final = "dev_id"
|
ATTR_DEV_ID: Final = "dev_id"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Provide functionality to keep track of devices."""
|
"""Provide functionality to keep track of devices."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any, final
|
from typing import TYPE_CHECKING, Any, final
|
||||||
|
|
||||||
from propcache.api import cached_property
|
from propcache.api import cached_property
|
||||||
|
|
||||||
@@ -16,8 +16,19 @@ from homeassistant.const import (
|
|||||||
STATE_NOT_HOME,
|
STATE_NOT_HOME,
|
||||||
EntityCategory,
|
EntityCategory,
|
||||||
)
|
)
|
||||||
from homeassistant.core import Event, HomeAssistant, State, callback
|
from homeassistant.core import (
|
||||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
CALLBACK_TYPE,
|
||||||
|
Event,
|
||||||
|
EventStateChangedData,
|
||||||
|
HomeAssistant,
|
||||||
|
State,
|
||||||
|
callback,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers import (
|
||||||
|
device_registry as dr,
|
||||||
|
entity_registry as er,
|
||||||
|
issue_registry as ir,
|
||||||
|
)
|
||||||
from homeassistant.helpers.device_registry import (
|
from homeassistant.helpers.device_registry import (
|
||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
EventDeviceRegistryUpdatedData,
|
EventDeviceRegistryUpdatedData,
|
||||||
@@ -25,6 +36,7 @@ from homeassistant.helpers.device_registry import (
|
|||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||||
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
@@ -33,6 +45,7 @@ from .const import (
|
|||||||
ATTR_IP,
|
ATTR_IP,
|
||||||
ATTR_MAC,
|
ATTR_MAC,
|
||||||
ATTR_SOURCE_TYPE,
|
ATTR_SOURCE_TYPE,
|
||||||
|
CONF_ASSOCIATED_ZONE,
|
||||||
CONNECTED_DEVICE_REGISTERED,
|
CONNECTED_DEVICE_REGISTERED,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
@@ -221,8 +234,8 @@ class TrackerEntity(
|
|||||||
"""Return the entity_id of zones the device is currently in.
|
"""Return the entity_id of zones the device is currently in.
|
||||||
|
|
||||||
The list may be in any order; the base class sorts it by zone radius
|
The list may be in any order; the base class sorts it by zone radius
|
||||||
and discards zones which do not exist. Ignored if latitude and
|
and discards zones which do not exist. Takes precedence over latitude
|
||||||
longitude are both set.
|
and longitude when set (including when set to an empty list).
|
||||||
"""
|
"""
|
||||||
return self._attr_in_zones
|
return self._attr_in_zones
|
||||||
|
|
||||||
@@ -252,11 +265,7 @@ class TrackerEntity(
|
|||||||
@callback
|
@callback
|
||||||
def _async_write_ha_state(self) -> None:
|
def _async_write_ha_state(self) -> None:
|
||||||
"""Calculate active zones."""
|
"""Calculate active zones."""
|
||||||
if self.available and self.latitude is not None and self.longitude is not None:
|
if (zones := self.in_zones) is not None:
|
||||||
self.__active_zone, self.__in_zones = zone.async_in_zones(
|
|
||||||
self.hass, self.latitude, self.longitude, self.location_accuracy
|
|
||||||
)
|
|
||||||
elif (zones := self.in_zones) is not None:
|
|
||||||
zone_states = sorted(
|
zone_states = sorted(
|
||||||
(
|
(
|
||||||
zone_state
|
zone_state
|
||||||
@@ -270,6 +279,12 @@ class TrackerEntity(
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
self.__in_zones = [z.entity_id for z in zone_states]
|
self.__in_zones = [z.entity_id for z in zone_states]
|
||||||
|
elif (
|
||||||
|
self.available and self.latitude is not None and self.longitude is not None
|
||||||
|
):
|
||||||
|
self.__active_zone, self.__in_zones = zone.async_in_zones(
|
||||||
|
self.hass, self.latitude, self.longitude, self.location_accuracy
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.__active_zone = None
|
self.__active_zone = None
|
||||||
self.__in_zones = None
|
self.__in_zones = None
|
||||||
@@ -317,14 +332,120 @@ class BaseScannerEntity(BaseTrackerEntity):
|
|||||||
addresses being used to identify the device.
|
addresses being used to identify the device.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
|
||||||
|
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
|
||||||
|
|
||||||
|
async def async_internal_added_to_hass(self) -> None:
|
||||||
|
"""Call when the scanner entity is added to hass."""
|
||||||
|
await super().async_internal_added_to_hass()
|
||||||
|
if not self.registry_entry:
|
||||||
|
return
|
||||||
|
self._async_read_entity_options()
|
||||||
|
|
||||||
|
async def async_internal_will_remove_from_hass(self) -> None:
|
||||||
|
"""Call when the scanner entity is about to be removed from hass."""
|
||||||
|
await super().async_internal_will_remove_from_hass()
|
||||||
|
if not self.registry_entry:
|
||||||
|
return
|
||||||
|
if self._scanner_option_associated_zone_unsub is not None:
|
||||||
|
self._scanner_option_associated_zone_unsub()
|
||||||
|
self._scanner_option_associated_zone_unsub = None
|
||||||
|
self._async_clear_associated_zone_issue()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_registry_entry_updated(self) -> None:
|
||||||
|
"""Run when the entity registry entry has been updated."""
|
||||||
|
self._async_read_entity_options()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_read_entity_options(self) -> None:
|
||||||
|
"""Read entity options from the entity registry.
|
||||||
|
|
||||||
|
Called when the entity registry entry has been updated and before the
|
||||||
|
scanner entity is added to the state machine.
|
||||||
|
"""
|
||||||
|
assert self.registry_entry
|
||||||
|
if (scanner_options := self.registry_entry.options.get(DOMAIN)) and (
|
||||||
|
associated_zone := scanner_options.get(CONF_ASSOCIATED_ZONE)
|
||||||
|
):
|
||||||
|
new_zone = associated_zone
|
||||||
|
else:
|
||||||
|
new_zone = zone.ENTITY_ID_HOME
|
||||||
|
|
||||||
|
if new_zone == self._scanner_option_associated_zone:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Tear down tracking for the previous zone.
|
||||||
|
if self._scanner_option_associated_zone_unsub is not None:
|
||||||
|
self._scanner_option_associated_zone_unsub()
|
||||||
|
self._scanner_option_associated_zone_unsub = None
|
||||||
|
self._async_clear_associated_zone_issue()
|
||||||
|
|
||||||
|
self._scanner_option_associated_zone = new_zone
|
||||||
|
|
||||||
|
# zone.home is always present so no tracking or issue handling needed.
|
||||||
|
if new_zone == zone.ENTITY_ID_HOME:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._scanner_option_associated_zone_unsub = async_track_state_change_event(
|
||||||
|
self.hass, new_zone, self._async_associated_zone_state_changed
|
||||||
|
)
|
||||||
|
if self.hass.states.get(new_zone) is None:
|
||||||
|
self._async_create_associated_zone_issue()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_associated_zone_state_changed(
|
||||||
|
self, event: Event[EventStateChangedData]
|
||||||
|
) -> None:
|
||||||
|
"""Open or clear the repair issue when the associated zone appears or disappears."""
|
||||||
|
if event.data["new_state"] is None:
|
||||||
|
self._async_create_associated_zone_issue()
|
||||||
|
else:
|
||||||
|
self._async_clear_associated_zone_issue()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_create_associated_zone_issue(self) -> None:
|
||||||
|
"""Create a repair issue prompting the user to reconfigure the scanner."""
|
||||||
|
ir.async_create_issue(
|
||||||
|
self.hass,
|
||||||
|
DOMAIN,
|
||||||
|
self._associated_zone_issue_id,
|
||||||
|
is_fixable=False,
|
||||||
|
severity=ir.IssueSeverity.WARNING,
|
||||||
|
translation_key="associated_zone_missing",
|
||||||
|
translation_placeholders={
|
||||||
|
"entity_id": self.entity_id,
|
||||||
|
"zone": self._scanner_option_associated_zone,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_clear_associated_zone_issue(self) -> None:
|
||||||
|
"""Clear the associated-zone-missing repair issue if it exists."""
|
||||||
|
ir.async_delete_issue(self.hass, DOMAIN, self._associated_zone_issue_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _associated_zone_issue_id(self) -> str:
|
||||||
|
"""Return the issue id for the associated-zone-missing repair."""
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert self.registry_entry
|
||||||
|
return f"associated_zone_missing_{self.registry_entry.id}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> str | None:
|
def state(self) -> str | None:
|
||||||
"""Return the state of the device."""
|
"""Return the state of the device."""
|
||||||
if self.is_connected is None:
|
if self.is_connected is None:
|
||||||
return None
|
return None
|
||||||
if self.is_connected:
|
if not self.is_connected:
|
||||||
|
return STATE_NOT_HOME
|
||||||
|
associated_zone = self._scanner_option_associated_zone
|
||||||
|
if associated_zone == zone.ENTITY_ID_HOME:
|
||||||
return STATE_HOME
|
return STATE_HOME
|
||||||
return STATE_NOT_HOME
|
if zone_state := self.hass.states.get(associated_zone):
|
||||||
|
return zone_state.name
|
||||||
|
# Configured zone has been removed; state is unknown.
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connected(self) -> bool | None:
|
def is_connected(self) -> bool | None:
|
||||||
@@ -341,9 +462,18 @@ class BaseScannerEntity(BaseTrackerEntity):
|
|||||||
if not self.is_connected:
|
if not self.is_connected:
|
||||||
return attr
|
return attr
|
||||||
|
|
||||||
|
associated_zone = self._scanner_option_associated_zone
|
||||||
|
# If the configured zone has been removed, in_zones stays empty so the
|
||||||
|
# attribute does not claim membership in a zone that no longer exists.
|
||||||
|
if (
|
||||||
|
associated_zone != zone.ENTITY_ID_HOME
|
||||||
|
and self.hass.states.get(associated_zone) is None
|
||||||
|
):
|
||||||
|
return attr
|
||||||
|
|
||||||
attr[ATTR_IN_ZONES] = [
|
attr[ATTR_IN_ZONES] = [
|
||||||
zone.ENTITY_ID_HOME,
|
associated_zone,
|
||||||
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
|
*zone.async_get_enclosing_zones(self.hass, associated_zone),
|
||||||
]
|
]
|
||||||
|
|
||||||
return attr
|
return attr
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ from homeassistant.const import (
|
|||||||
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||||
|
from homeassistant.helpers.entity_platform import (
|
||||||
|
async_create_platform_config_not_supported_issue,
|
||||||
|
)
|
||||||
from homeassistant.helpers.event import (
|
from homeassistant.helpers.event import (
|
||||||
async_track_time_interval,
|
async_track_time_interval,
|
||||||
async_track_utc_time_change,
|
async_track_utc_time_change,
|
||||||
@@ -379,8 +382,8 @@ async def async_extract_config(
|
|||||||
if platform.type == PLATFORM_TYPE_LEGACY:
|
if platform.type == PLATFORM_TYPE_LEGACY:
|
||||||
legacy.append(platform)
|
legacy.append(platform)
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
async_create_platform_config_not_supported_issue(
|
||||||
f"Unable to determine type for {platform.name}: {platform.type}"
|
hass, platform.name, DOMAIN
|
||||||
)
|
)
|
||||||
|
|
||||||
return legacy
|
return legacy
|
||||||
|
|||||||
@@ -44,6 +44,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"associated_zone_missing": {
|
||||||
|
"description": "The scanner entity `{entity_id}` is associated with the zone `{zone}`, but that zone has been removed.\n\nTo fix this, reconfigure the scanner to use a different zone or recreate the missing zone.",
|
||||||
|
"title": "Scanner is associated with a removed zone"
|
||||||
|
}
|
||||||
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"see": {
|
"see": {
|
||||||
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
|
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.trigger_common_fields: &trigger_common_fields
|
.trigger_common_fields: &trigger_common_fields
|
||||||
behavior:
|
behavior:
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -64,23 +64,23 @@
|
|||||||
"ventilation_state": {
|
"ventilation_state": {
|
||||||
"name": "Ventilation state",
|
"name": "Ventilation state",
|
||||||
"state": {
|
"state": {
|
||||||
"aut1": "Automatic boost (15 min)",
|
"aut1": "AUT1",
|
||||||
"aut2": "Automatic boost (30 min)",
|
"aut2": "AUT2",
|
||||||
"aut3": "Automatic boost (45 min)",
|
"aut3": "AUT3",
|
||||||
"auto": "Automatic",
|
"auto": "AUTO",
|
||||||
"cnt1": "Continuous low speed",
|
"cnt1": "CNT1",
|
||||||
"cnt2": "Continuous medium speed",
|
"cnt2": "CNT2",
|
||||||
"cnt3": "Continuous high speed",
|
"cnt3": "CNT3",
|
||||||
"empt": "Empty house",
|
"empt": "EMPT",
|
||||||
"man1": "Manual low speed (15 min)",
|
"man1": "MAN1",
|
||||||
"man1x2": "Manual low speed (30 min)",
|
"man1x2": "MAN1x2",
|
||||||
"man1x3": "Manual low speed (45 min)",
|
"man1x3": "MAN1x3",
|
||||||
"man2": "Manual medium speed (15 min)",
|
"man2": "MAN2",
|
||||||
"man2x2": "Manual medium speed (30 min)",
|
"man2x2": "MAN2x2",
|
||||||
"man2x3": "Manual medium speed (45 min)",
|
"man2x3": "MAN2x3",
|
||||||
"man3": "Manual high speed (15 min)",
|
"man3": "MAN3",
|
||||||
"man3x2": "Manual high speed (30 min)",
|
"man3x2": "MAN3x2",
|
||||||
"man3x3": "Manual high speed (45 min)"
|
"man3x3": "MAN3x3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
self.stations = {}
|
self.stations = {}
|
||||||
for station in stations:
|
for station in stations:
|
||||||
label = station["label"]
|
label = station["label"]
|
||||||
rloId = station["RLOIid"]
|
rlo_id = station["RLOIid"]
|
||||||
|
|
||||||
# API annoyingly sometimes returns a list and some times returns a string
|
# API annoyingly sometimes returns a list and some times returns a string
|
||||||
# E.g. L3121 has a label of ['Scurf Dyke', 'Scurf Dyke Dyke Level']
|
# E.g. L3121 has a label of ['Scurf Dyke', 'Scurf Dyke Dyke Level']
|
||||||
@@ -50,11 +50,11 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
# Similar for RLOIid
|
# Similar for RLOIid
|
||||||
# E.g. 0018 has an RLOIid of ['10427', '9154']
|
# E.g. 0018 has an RLOIid of ['10427', '9154']
|
||||||
if isinstance(rloId, list):
|
if isinstance(rlo_id, list):
|
||||||
rloId = rloId[-1]
|
rlo_id = rlo_id[-1]
|
||||||
|
|
||||||
fullName = label + " - " + rloId
|
full_name = label + " - " + rlo_id
|
||||||
self.stations[fullName] = station["stationReference"]
|
self.stations[full_name] = station["stationReference"]
|
||||||
|
|
||||||
if not self.stations:
|
if not self.stations:
|
||||||
return self.async_abort(reason="no_stations")
|
return self.async_abort(reason="no_stations")
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ ELK_ELEMENTS = {
|
|||||||
EVENT_ELKM1_KEYPAD_KEY_PRESSED = "elkm1.keypad_key_pressed"
|
EVENT_ELKM1_KEYPAD_KEY_PRESSED = "elkm1.keypad_key_pressed"
|
||||||
|
|
||||||
|
|
||||||
|
ATTR_DURATION = "duration"
|
||||||
ATTR_KEYPAD_ID = "keypad_id"
|
ATTR_KEYPAD_ID = "keypad_id"
|
||||||
ATTR_KEY = "key"
|
ATTR_KEY = "key"
|
||||||
ATTR_KEY_NAME = "key_name"
|
ATTR_KEY_NAME = "key_name"
|
||||||
|
|||||||
@@ -48,6 +48,9 @@
|
|||||||
},
|
},
|
||||||
"speak_word": {
|
"speak_word": {
|
||||||
"service": "mdi:message-minus"
|
"service": "mdi:message-minus"
|
||||||
|
},
|
||||||
|
"switch_output_turn_on_for": {
|
||||||
|
"service": "mdi:timer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,3 +161,15 @@ sensor_zone_trigger:
|
|||||||
entity:
|
entity:
|
||||||
integration: elkm1
|
integration: elkm1
|
||||||
domain: sensor
|
domain: sensor
|
||||||
|
|
||||||
|
switch_output_turn_on_for:
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: elkm1
|
||||||
|
domain: switch
|
||||||
|
fields:
|
||||||
|
duration:
|
||||||
|
example: 42
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
duration:
|
||||||
|
|||||||
@@ -210,6 +210,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"name": "Speak word"
|
"name": "Speak word"
|
||||||
|
},
|
||||||
|
"switch_output_turn_on_for": {
|
||||||
|
"description": "Turns on an output for a specified length of time.",
|
||||||
|
"fields": {
|
||||||
|
"duration": {
|
||||||
|
"description": "Length of time to turn the output on for.",
|
||||||
|
"name": "Duration"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Switch output turn on for"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Support for control of ElkM1 outputs (relays)."""
|
"""Support for control of ElkM1 outputs (relays)."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from math import ceil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from elkm1_lib.const import ThermostatMode, ThermostatSetting
|
from elkm1_lib.const import ThermostatMode, ThermostatSetting
|
||||||
@@ -7,15 +9,29 @@ from elkm1_lib.elements import Element
|
|||||||
from elkm1_lib.elk import Elk
|
from elkm1_lib.elk import Elk
|
||||||
from elkm1_lib.outputs import Output
|
from elkm1_lib.outputs import Output
|
||||||
from elkm1_lib.thermostats import Thermostat
|
from elkm1_lib.thermostats import Thermostat
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.switch import SwitchEntity
|
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import config_validation as cv, service
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
from homeassistant.helpers.typing import VolDictType
|
||||||
|
|
||||||
from . import ElkM1ConfigEntry
|
from . import ElkM1ConfigEntry
|
||||||
|
from .const import ATTR_DURATION, DOMAIN
|
||||||
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
|
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
|
||||||
from .models import ELKM1Data
|
from .models import ELKM1Data
|
||||||
|
|
||||||
|
SERVICE_SWITCH_OUTPUT_TURN_ON_FOR = "switch_output_turn_on_for"
|
||||||
|
|
||||||
|
ELK_OUTPUT_TURN_ON_FOR_SERVICE_SCHEMA: VolDictType = {
|
||||||
|
vol.Required(ATTR_DURATION): vol.All(
|
||||||
|
cv.time_period,
|
||||||
|
vol.Range(min=timedelta(seconds=1), max=timedelta(seconds=65535)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -32,6 +48,15 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
service.async_register_platform_entity_service(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_SWITCH_OUTPUT_TURN_ON_FOR,
|
||||||
|
entity_domain=SWITCH_DOMAIN,
|
||||||
|
schema=ELK_OUTPUT_TURN_ON_FOR_SERVICE_SCHEMA,
|
||||||
|
func="async_switch_output_turn_on_for",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ElkOutput(ElkAttachedEntity, SwitchEntity):
|
class ElkOutput(ElkAttachedEntity, SwitchEntity):
|
||||||
"""Elk output as switch."""
|
"""Elk output as switch."""
|
||||||
@@ -51,6 +76,10 @@ class ElkOutput(ElkAttachedEntity, SwitchEntity):
|
|||||||
"""Turn off the output."""
|
"""Turn off the output."""
|
||||||
self._element.turn_off()
|
self._element.turn_off()
|
||||||
|
|
||||||
|
async def async_switch_output_turn_on_for(self, duration: timedelta) -> None:
|
||||||
|
"""Turn on an output for specified length of time."""
|
||||||
|
self._element.turn_on(ceil(duration.total_seconds()))
|
||||||
|
|
||||||
|
|
||||||
class ElkThermostatEMHeat(ElkEntity, SwitchEntity):
|
class ElkThermostatEMHeat(ElkEntity, SwitchEntity):
|
||||||
"""Elk Thermostat emergency heat as switch."""
|
"""Elk Thermostat emergency heat as switch."""
|
||||||
@@ -79,3 +108,7 @@ class ElkThermostatEMHeat(ElkEntity, SwitchEntity):
|
|||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn off the output."""
|
"""Turn off the output."""
|
||||||
self._elk_set(ThermostatMode.EMERGENCY_HEAT)
|
self._elk_set(ThermostatMode.EMERGENCY_HEAT)
|
||||||
|
|
||||||
|
async def async_switch_output_turn_on_for(self, duration: timedelta) -> None:
|
||||||
|
"""Turn on an output for specified length of time: not supported for thermostat."""
|
||||||
|
raise HomeAssistantError("supported only on ElkM1 output switch entities")
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"mqtt": ["esphome/discover/#"],
|
"mqtt": ["esphome/discover/#"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"aioesphomeapi==45.2.2",
|
"aioesphomeapi==45.3.1",
|
||||||
"esphome-dashboard-api==1.3.0",
|
"esphome-dashboard-api==1.3.0",
|
||||||
"bleak-esphome==3.9.1"
|
"bleak-esphome==3.9.1"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -124,11 +124,11 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
for camera in coordinator.data:
|
for camera in coordinator.data:
|
||||||
device_category = coordinator.data[camera].get("device_category")
|
device_category = coordinator.data[camera].get("device_category")
|
||||||
supportExt = coordinator.data[camera].get("supportExt")
|
support_ext = coordinator.data[camera].get("supportExt")
|
||||||
if (
|
if (
|
||||||
device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value
|
device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value
|
||||||
and supportExt
|
and support_ext
|
||||||
and str(SupportExt.SupportBatteryManage.value) in supportExt
|
and str(SupportExt.SupportBatteryManage.value) in support_ext
|
||||||
):
|
):
|
||||||
entities.append(
|
entities.append(
|
||||||
EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE)
|
EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
fields:
|
fields:
|
||||||
behavior:
|
behavior:
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -21,5 +21,5 @@
|
|||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"preview_features": { "winter_mode": {} },
|
"preview_features": { "winter_mode": {} },
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["home-assistant-frontend==20260429.4"]
|
"requirements": ["home-assistant-frontend==20260527.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.trigger_common_fields: &trigger_common_fields
|
.trigger_common_fields: &trigger_common_fields
|
||||||
behavior:
|
behavior:
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.trigger_common_fields: &trigger_common_fields
|
.trigger_common_fields: &trigger_common_fields
|
||||||
behavior:
|
behavior:
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -7,19 +7,30 @@ from google_air_quality_api.auth import Auth
|
|||||||
|
|
||||||
from homeassistant.const import CONF_API_KEY, Platform
|
from homeassistant.const import CONF_API_KEY, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import CONF_REFERRER
|
from .const import CONF_REFERRER, DOMAIN
|
||||||
from .coordinator import (
|
from .coordinator import (
|
||||||
GoogleAirQualityConfigEntry,
|
GoogleAirQualityConfigEntry,
|
||||||
GoogleAirQualityRuntimeData,
|
GoogleAirQualityRuntimeData,
|
||||||
GoogleAirQualityUpdateCoordinator,
|
GoogleAirQualityUpdateCoordinator,
|
||||||
)
|
)
|
||||||
|
from .services import async_setup_services
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [
|
PLATFORMS: list[Platform] = [
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
|
"""Set up the Google Air Quality integration."""
|
||||||
|
async_setup_services(hass)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: GoogleAirQualityConfigEntry
|
hass: HomeAssistant, entry: GoogleAirQualityConfigEntry
|
||||||
|
|||||||
@@ -11,5 +11,10 @@
|
|||||||
"default": "mdi:molecule"
|
"default": "mdi:molecule"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"get_forecast": {
|
||||||
|
"service": "mdi:clock-end"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Services for the Google Air Quality integration."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Final, cast
|
||||||
|
|
||||||
|
from google_air_quality_api.exceptions import GoogleAirQualityApiError
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import ATTR_DEVICE_ID
|
||||||
|
from homeassistant.core import (
|
||||||
|
HomeAssistant,
|
||||||
|
ServiceCall,
|
||||||
|
ServiceResponse,
|
||||||
|
SupportsResponse,
|
||||||
|
callback,
|
||||||
|
)
|
||||||
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
|
from homeassistant.helpers import device_registry as dr, selector
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import GoogleAirQualityConfigEntry
|
||||||
|
|
||||||
|
ATTR_HOURS: Final = "hours"
|
||||||
|
|
||||||
|
FORECAST_HOURS_MAX: Final = 96
|
||||||
|
|
||||||
|
SERVICE_GET_FORECAST: Final = "get_forecast"
|
||||||
|
|
||||||
|
SERVICE_GET_FORECAST_SCHEMA: Final = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector({"integration": DOMAIN}),
|
||||||
|
vol.Required(ATTR_HOURS): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=1, max=FORECAST_HOURS_MAX)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config_entry_and_subentry_id(
|
||||||
|
hass: HomeAssistant, device_id: str
|
||||||
|
) -> tuple[GoogleAirQualityConfigEntry, str]:
|
||||||
|
"""Get the config entry and subentry from a selected location device."""
|
||||||
|
device = dr.async_get(hass).async_get(device_id)
|
||||||
|
if device is not None:
|
||||||
|
for entry_id, subentry_ids in device.config_entries_subentries.items():
|
||||||
|
config_entry: ConfigEntry | None = hass.config_entries.async_get_entry(
|
||||||
|
entry_id
|
||||||
|
)
|
||||||
|
if config_entry is None or config_entry.domain != DOMAIN:
|
||||||
|
continue
|
||||||
|
|
||||||
|
gaq_config_entry = cast(GoogleAirQualityConfigEntry, config_entry)
|
||||||
|
for subentry_id in subentry_ids:
|
||||||
|
if (
|
||||||
|
subentry_id is not None
|
||||||
|
and subentry_id
|
||||||
|
in gaq_config_entry.runtime_data.subentries_runtime_data
|
||||||
|
):
|
||||||
|
return gaq_config_entry, subentry_id
|
||||||
|
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="device_not_found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_get_forecast(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Fetch the air quality forecast for a configured location."""
|
||||||
|
config_entry, subentry_id = _get_config_entry_and_subentry_id(
|
||||||
|
call.hass, call.data[ATTR_DEVICE_ID]
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator = config_entry.runtime_data.subentries_runtime_data[subentry_id]
|
||||||
|
|
||||||
|
try:
|
||||||
|
forecast = await config_entry.runtime_data.api.async_get_forecast(
|
||||||
|
coordinator.lat,
|
||||||
|
coordinator.long,
|
||||||
|
timedelta(hours=call.data[ATTR_HOURS]),
|
||||||
|
)
|
||||||
|
except GoogleAirQualityApiError as err:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="unable_to_fetch",
|
||||||
|
) from err
|
||||||
|
|
||||||
|
return cast(
|
||||||
|
ServiceResponse,
|
||||||
|
{
|
||||||
|
"forecast_time": forecast.hourly_forecasts[0].date_time,
|
||||||
|
"indexes": forecast.hourly_forecasts[0].indexes,
|
||||||
|
"pollutants": forecast.hourly_forecasts[0].pollutants,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Set up services."""
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_GET_FORECAST,
|
||||||
|
_async_get_forecast,
|
||||||
|
schema=SERVICE_GET_FORECAST_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.ONLY,
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
get_forecast:
|
||||||
|
fields:
|
||||||
|
device_id:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
device:
|
||||||
|
integration: google_air_quality
|
||||||
|
hours:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 1
|
||||||
|
max: 96
|
||||||
|
step: 1
|
||||||
|
mode: box
|
||||||
@@ -270,8 +270,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
|
"device_not_found": {
|
||||||
|
"message": "Location not found."
|
||||||
|
},
|
||||||
"unable_to_fetch": {
|
"unable_to_fetch": {
|
||||||
"message": "[%key:component::google_air_quality::common::unable_to_fetch%]"
|
"message": "[%key:component::google_air_quality::common::unable_to_fetch%]"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"get_forecast": {
|
||||||
|
"description": "Get an air quality forecast for a configured location.",
|
||||||
|
"fields": {
|
||||||
|
"device_id": {
|
||||||
|
"description": "The location to fetch the forecast for.",
|
||||||
|
"name": "Location"
|
||||||
|
},
|
||||||
|
"hours": {
|
||||||
|
"description": "How many hours into the future to forecast.",
|
||||||
|
"name": "Hours"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Get forecast"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,13 +117,13 @@ class DriveClient:
|
|||||||
"""Get storage quota of the current user."""
|
"""Get storage quota of the current user."""
|
||||||
res = await self._api.get_user(params={"fields": "storageQuota"})
|
res = await self._api.get_user(params={"fields": "storageQuota"})
|
||||||
|
|
||||||
storageQuota = res["storageQuota"]
|
storage_quota = res["storageQuota"]
|
||||||
limit = storageQuota.get("limit")
|
limit = storage_quota.get("limit")
|
||||||
return StorageQuotaData(
|
return StorageQuotaData(
|
||||||
limit=int(limit) if limit is not None else None,
|
limit=int(limit) if limit is not None else None,
|
||||||
usage=int(storageQuota.get("usage", 0)),
|
usage=int(storage_quota.get("usage", 0)),
|
||||||
usage_in_drive=int(storageQuota.get("usageInDrive", 0)),
|
usage_in_drive=int(storage_quota.get("usageInDrive", 0)),
|
||||||
usage_in_trash=int(storageQuota.get("usageInTrash", 0)),
|
usage_in_trash=int(storage_quota.get("usageInTrash", 0)),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_create_ha_root_folder_if_not_exists(self) -> tuple[str, str]:
|
async def async_create_ha_root_folder_if_not_exists(self) -> tuple[str, str]:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ DEFAULT_STT_PROMPT = "Transcribe the attached audio"
|
|||||||
|
|
||||||
CONF_RECOMMENDED = "recommended"
|
CONF_RECOMMENDED = "recommended"
|
||||||
CONF_CHAT_MODEL = "chat_model"
|
CONF_CHAT_MODEL = "chat_model"
|
||||||
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
|
RECOMMENDED_CHAT_MODEL = "models/gemini-3.1-flash-lite"
|
||||||
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
|
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
|
||||||
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
|
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
|
||||||
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
|
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
|
||||||
|
|||||||
@@ -580,17 +580,17 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
|||||||
|
|
||||||
if tool_results:
|
if tool_results:
|
||||||
messages.append(_create_google_tool_response_content(tool_results))
|
messages.append(_create_google_tool_response_content(tool_results))
|
||||||
generateContentConfig = self.create_generate_content_config()
|
generate_content_config = self.create_generate_content_config()
|
||||||
generateContentConfig.tools = tools or None
|
generate_content_config.tools = tools or None
|
||||||
generateContentConfig.system_instruction = (
|
generate_content_config.system_instruction = (
|
||||||
prompt if supports_system_instruction else None
|
prompt if supports_system_instruction else None
|
||||||
)
|
)
|
||||||
generateContentConfig.automatic_function_calling = (
|
generate_content_config.automatic_function_calling = (
|
||||||
AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None)
|
AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None)
|
||||||
)
|
)
|
||||||
if structure:
|
if structure:
|
||||||
generateContentConfig.response_mime_type = "application/json"
|
generate_content_config.response_mime_type = "application/json"
|
||||||
generateContentConfig.response_schema = _format_schema(
|
generate_content_config.response_schema = _format_schema(
|
||||||
convert(
|
convert(
|
||||||
structure,
|
structure,
|
||||||
custom_serializer=(
|
custom_serializer=(
|
||||||
@@ -608,7 +608,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
|||||||
*messages,
|
*messages,
|
||||||
]
|
]
|
||||||
chat = self._genai_client.aio.chats.create(
|
chat = self._genai_client.aio.chats.create(
|
||||||
model=model_name, history=messages, config=generateContentConfig
|
model=model_name, history=messages, config=generate_content_config
|
||||||
)
|
)
|
||||||
user_message = chat_log.content[-1]
|
user_message = chat_log.content[-1]
|
||||||
assert isinstance(user_message, conversation.UserContent)
|
assert isinstance(user_message, conversation.UserContent)
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse:
|
|||||||
)
|
)
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
FUNC_MAP = {
|
func_map = {
|
||||||
SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest,
|
SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest,
|
||||||
SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest,
|
SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest,
|
||||||
SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest,
|
SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest,
|
||||||
@@ -322,7 +322,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse:
|
|||||||
SERVICE_START_QUEST: coordinator.habitica.start_quest,
|
SERVICE_START_QUEST: coordinator.habitica.start_quest,
|
||||||
}
|
}
|
||||||
|
|
||||||
func = FUNC_MAP[call.service]
|
func = func_map[call.service]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await func()
|
response = await func()
|
||||||
|
|||||||
@@ -131,12 +131,8 @@ ATTR_AUTO_UPDATE = "auto_update"
|
|||||||
ATTR_VERSION = "version"
|
ATTR_VERSION = "version"
|
||||||
ATTR_VERSION_LATEST = "version_latest"
|
ATTR_VERSION_LATEST = "version_latest"
|
||||||
ATTR_CPU_PERCENT = "cpu_percent"
|
ATTR_CPU_PERCENT = "cpu_percent"
|
||||||
# pylint: disable-next=home-assistant-duplicate-const
|
|
||||||
ATTR_LOCATION = "location"
|
|
||||||
ATTR_MEMORY_PERCENT = "memory_percent"
|
ATTR_MEMORY_PERCENT = "memory_percent"
|
||||||
ATTR_SLUG = "slug"
|
ATTR_SLUG = "slug"
|
||||||
# pylint: disable-next=home-assistant-duplicate-const
|
|
||||||
ATTR_STATE = "state"
|
|
||||||
ATTR_STARTED = "started"
|
ATTR_STARTED = "started"
|
||||||
ATTR_URL = "url"
|
ATTR_URL = "url"
|
||||||
ATTR_REPOSITORY = "repository"
|
ATTR_REPOSITORY = "repository"
|
||||||
@@ -177,19 +173,6 @@ CORE_CONTAINER = "homeassistant"
|
|||||||
SUPERVISOR_CONTAINER = "hassio_supervisor"
|
SUPERVISOR_CONTAINER = "hassio_supervisor"
|
||||||
|
|
||||||
CONTAINER_STATS = "stats"
|
CONTAINER_STATS = "stats"
|
||||||
CONTAINER_INFO = "info"
|
|
||||||
|
|
||||||
# This is a mapping of which endpoint the key in the addon data
|
|
||||||
# is obtained from so we know which endpoint to update when the
|
|
||||||
# coordinator polls for updates.
|
|
||||||
KEY_TO_UPDATE_TYPES: dict[str, set[str]] = {
|
|
||||||
ATTR_VERSION_LATEST: {CONTAINER_INFO},
|
|
||||||
ATTR_MEMORY_PERCENT: {CONTAINER_STATS},
|
|
||||||
ATTR_CPU_PERCENT: {CONTAINER_STATS},
|
|
||||||
ATTR_VERSION: {CONTAINER_INFO},
|
|
||||||
ATTR_STATE: {CONTAINER_INFO},
|
|
||||||
}
|
|
||||||
|
|
||||||
REQUEST_REFRESH_DELAY = 10
|
REQUEST_REFRESH_DELAY = 10
|
||||||
|
|
||||||
HELP_URLS = {
|
HELP_URLS = {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from aiohasupervisor.models import (
|
|||||||
)
|
)
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
|
from homeassistant.const import ATTR_DEVICE_ID, ATTR_LOCATION, ATTR_NAME
|
||||||
from homeassistant.core import (
|
from homeassistant.core import (
|
||||||
HomeAssistant,
|
HomeAssistant,
|
||||||
ServiceCall,
|
ServiceCall,
|
||||||
@@ -43,7 +43,6 @@ from .const import (
|
|||||||
ATTR_HOMEASSISTANT,
|
ATTR_HOMEASSISTANT,
|
||||||
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
|
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
|
||||||
ATTR_INPUT,
|
ATTR_INPUT,
|
||||||
ATTR_LOCATION,
|
|
||||||
ATTR_PASSWORD,
|
ATTR_PASSWORD,
|
||||||
ATTR_SLUG,
|
ATTR_SLUG,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "Home Assistant Hardware",
|
"name": "Home Assistant Hardware",
|
||||||
"after_dependencies": ["hassio"],
|
"after_dependencies": ["hassio"],
|
||||||
"codeowners": ["@home-assistant/core"],
|
"codeowners": ["@home-assistant/core"],
|
||||||
"dependencies": ["usb"],
|
"dependencies": ["repairs", "usb"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Repairs for the Home Assistant Hardware integration."""
|
||||||
|
|
||||||
|
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
|
|
||||||
|
ISSUE_MULTI_PAN_MIGRATION = "multi_pan_migration"
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _multi_pan_issue_id(config_entry: ConfigEntry) -> str:
|
||||||
|
"""Return the issue id for the multi-PAN migration issue of an entry."""
|
||||||
|
return f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}"
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_create_multi_pan_migration_issue(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
domain: str,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Create a repair issue to guide migration away from Multi-PAN."""
|
||||||
|
ir.async_create_issue(
|
||||||
|
hass,
|
||||||
|
domain=domain,
|
||||||
|
issue_id=_multi_pan_issue_id(config_entry),
|
||||||
|
is_fixable=True,
|
||||||
|
is_persistent=False,
|
||||||
|
severity=ir.IssueSeverity.WARNING,
|
||||||
|
translation_key=ISSUE_MULTI_PAN_MIGRATION,
|
||||||
|
translation_placeholders={"hardware_name": config_entry.title},
|
||||||
|
data={"entry_id": config_entry.entry_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_delete_multi_pan_migration_issue(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
domain: str,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Delete the multi-PAN migration repair issue for this entry."""
|
||||||
|
ir.async_delete_issue(hass, domain, _multi_pan_issue_id(config_entry))
|
||||||
|
|
||||||
|
|
||||||
|
class MultiPanMigrationRepairFlow(RepairsFlow):
|
||||||
|
"""Reuse the multi-PAN options flow uninstall steps as a repair flow.
|
||||||
|
|
||||||
|
Subclass this together with the hardware-specific
|
||||||
|
``MultiPanOptionsFlowHandler`` in each hardware integration's repairs
|
||||||
|
module.
|
||||||
|
|
||||||
|
The repair flow runs in the repairs flow manager where ``self.handler``
|
||||||
|
is the integration domain rather than the hardware config entry id, so
|
||||||
|
the ``config_entry`` accessor of ``OptionsFlow`` must be overridden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_repair_config_entry: ConfigEntry
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_entry(self) -> ConfigEntry:
|
||||||
|
"""Return the hardware config entry to migrate."""
|
||||||
|
return self._repair_config_entry
|
||||||
|
|
||||||
|
async def _async_step_start_migration(self) -> RepairsFlowResult:
|
||||||
|
"""Jump straight into the uninstall step of the migration flow.
|
||||||
|
|
||||||
|
The repair flow's init data is the issue context, not user form input,
|
||||||
|
so pass None to render the uninstall confirmation form.
|
||||||
|
"""
|
||||||
|
return await self.async_step_uninstall_addon() # type: ignore[attr-defined, no-any-return]
|
||||||
@@ -6,6 +6,8 @@ import dataclasses
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from aiohttp import ClientError
|
||||||
|
from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
import yarl
|
import yarl
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ from homeassistant.config_entries import (
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.data_entry_flow import AbortFlow
|
from homeassistant.data_entry_flow import AbortFlow
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
from homeassistant.helpers.integration_platform import (
|
from homeassistant.helpers.integration_platform import (
|
||||||
async_process_integration_platforms,
|
async_process_integration_platforms,
|
||||||
@@ -37,15 +40,18 @@ from homeassistant.helpers.selector import (
|
|||||||
from homeassistant.helpers.singleton import singleton
|
from homeassistant.helpers.singleton import singleton
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
from .const import LOGGER, SILABS_FLASHER_ADDON_SLUG, SILABS_MULTIPROTOCOL_ADDON_SLUG
|
from .const import DOMAIN, LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG
|
||||||
|
from .util import (
|
||||||
|
ApplicationType,
|
||||||
|
WaitingAddonManager,
|
||||||
|
async_firmware_flashing_context,
|
||||||
|
async_flash_silabs_firmware,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DATA_MULTIPROTOCOL_ADDON_MANAGER = "silabs_multiprotocol_addon_manager"
|
DATA_MULTIPROTOCOL_ADDON_MANAGER = "silabs_multiprotocol_addon_manager"
|
||||||
DATA_FLASHER_ADDON_MANAGER = "silabs_flasher"
|
|
||||||
|
|
||||||
ADDON_STATE_POLL_INTERVAL = 3
|
|
||||||
ADDON_INFO_POLL_TIMEOUT = 15 * 60
|
|
||||||
|
|
||||||
CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware"
|
CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware"
|
||||||
CONF_ADDON_DEVICE = "device"
|
CONF_ADDON_DEVICE = "device"
|
||||||
@@ -71,53 +77,6 @@ async def get_multiprotocol_addon_manager(
|
|||||||
return manager
|
return manager
|
||||||
|
|
||||||
|
|
||||||
class WaitingAddonManager(AddonManager):
|
|
||||||
"""Addon manager which supports waiting operations for managing an addon."""
|
|
||||||
|
|
||||||
async def async_wait_until_addon_state(self, *states: AddonState) -> None:
|
|
||||||
"""Poll an addon's info until it is in a specific state."""
|
|
||||||
async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT):
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
info = await self.async_get_addon_info()
|
|
||||||
except AddonError:
|
|
||||||
info = None
|
|
||||||
|
|
||||||
_LOGGER.debug("Waiting for addon to be in state %s: %s", states, info)
|
|
||||||
|
|
||||||
if info is not None and info.state in states:
|
|
||||||
break
|
|
||||||
|
|
||||||
await asyncio.sleep(ADDON_STATE_POLL_INTERVAL)
|
|
||||||
|
|
||||||
async def async_start_addon_waiting(self) -> None:
|
|
||||||
"""Start an add-on."""
|
|
||||||
await self.async_schedule_start_addon()
|
|
||||||
await self.async_wait_until_addon_state(AddonState.RUNNING)
|
|
||||||
|
|
||||||
async def async_install_addon_waiting(self) -> None:
|
|
||||||
"""Install an add-on."""
|
|
||||||
await self.async_schedule_install_addon()
|
|
||||||
await self.async_wait_until_addon_state(
|
|
||||||
AddonState.RUNNING,
|
|
||||||
AddonState.NOT_RUNNING,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_uninstall_addon_waiting(self) -> None:
|
|
||||||
"""Uninstall an add-on."""
|
|
||||||
try:
|
|
||||||
info = await self.async_get_addon_info()
|
|
||||||
except AddonError:
|
|
||||||
info = None
|
|
||||||
|
|
||||||
# Do not try to uninstall an addon if it is already uninstalled
|
|
||||||
if info is not None and info.state is AddonState.NOT_INSTALLED:
|
|
||||||
return
|
|
||||||
|
|
||||||
await self.async_uninstall_addon()
|
|
||||||
await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED)
|
|
||||||
|
|
||||||
|
|
||||||
class MultiprotocolAddonManager(WaitingAddonManager):
|
class MultiprotocolAddonManager(WaitingAddonManager):
|
||||||
"""Silicon Labs Multiprotocol add-on manager."""
|
"""Silicon Labs Multiprotocol add-on manager."""
|
||||||
|
|
||||||
@@ -265,18 +224,6 @@ class MultipanProtocol(Protocol):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@singleton(DATA_FLASHER_ADDON_MANAGER)
|
|
||||||
@callback
|
|
||||||
def get_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
|
|
||||||
"""Get the flasher add-on manager."""
|
|
||||||
return WaitingAddonManager(
|
|
||||||
hass,
|
|
||||||
LOGGER,
|
|
||||||
"Silicon Labs Flasher",
|
|
||||||
SILABS_FLASHER_ADDON_SLUG,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class SerialPortSettings:
|
class SerialPortSettings:
|
||||||
"""Serial port settings."""
|
"""Serial port settings."""
|
||||||
@@ -339,6 +286,19 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
|||||||
def _zha_name(self) -> str:
|
def _zha_name(self) -> str:
|
||||||
"""Return the ZHA name."""
|
"""Return the ZHA name."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _firmware_update_url(self) -> str:
|
||||||
|
"""Return the firmware update manifest URL."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _zigbee_firmware_type(self) -> str:
|
||||||
|
"""Return the zigbee firmware type identifier (e.g. 'yellow_zigbee_ncp')."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def _flasher_cls(self) -> type:
|
||||||
|
"""Return the hardware-specific flasher class."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def flow_manager(self) -> OptionsFlowManager:
|
def flow_manager(self) -> OptionsFlowManager:
|
||||||
"""Return the correct flow manager."""
|
"""Return the correct flow manager."""
|
||||||
@@ -686,61 +646,7 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
|||||||
async def async_step_firmware_revert(
|
async def async_step_firmware_revert(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Install the flasher addon, if necessary."""
|
"""Initiate ZHA backup and start multiprotocol addon uninstall."""
|
||||||
|
|
||||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
|
||||||
addon_info = await self._async_get_addon_info(flasher_manager)
|
|
||||||
|
|
||||||
if addon_info.state is AddonState.NOT_INSTALLED:
|
|
||||||
return await self.async_step_install_flasher_addon()
|
|
||||||
|
|
||||||
if addon_info.state is AddonState.NOT_RUNNING:
|
|
||||||
return await self.async_step_configure_flasher_addon()
|
|
||||||
|
|
||||||
# If the addon is already installed and running, fail
|
|
||||||
return self.async_abort(
|
|
||||||
reason="addon_already_running",
|
|
||||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_install_flasher_addon(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Show progress dialog for installing flasher addon."""
|
|
||||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
|
||||||
addon_info = await self._async_get_addon_info(flasher_manager)
|
|
||||||
|
|
||||||
_LOGGER.debug("Flasher addon state: %s", addon_info)
|
|
||||||
|
|
||||||
if not self.install_task:
|
|
||||||
self.install_task = self.hass.async_create_task(
|
|
||||||
flasher_manager.async_install_addon_waiting(),
|
|
||||||
"SiLabs Flasher addon install",
|
|
||||||
eager_start=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.install_task.done():
|
|
||||||
return self.async_show_progress(
|
|
||||||
step_id="install_flasher_addon",
|
|
||||||
progress_action="install_addon",
|
|
||||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
|
||||||
progress_task=self.install_task,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.install_task
|
|
||||||
except AddonError as err:
|
|
||||||
_LOGGER.error(err)
|
|
||||||
return self.async_show_progress_done(next_step_id="install_failed")
|
|
||||||
finally:
|
|
||||||
self.install_task = None
|
|
||||||
|
|
||||||
return self.async_show_progress_done(next_step_id="configure_flasher_addon")
|
|
||||||
|
|
||||||
async def async_step_configure_flasher_addon(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Perform initial backup and reconfigure ZHA."""
|
|
||||||
# pylint: disable=home-assistant-component-root-import
|
# pylint: disable=home-assistant-component-root-import
|
||||||
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415
|
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415
|
||||||
from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415
|
from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415
|
||||||
@@ -782,17 +688,6 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
|||||||
_LOGGER.exception("Unexpected exception during ZHA migration")
|
_LOGGER.exception("Unexpected exception during ZHA migration")
|
||||||
raise AbortFlow("zha_migration_failed") from err
|
raise AbortFlow("zha_migration_failed") from err
|
||||||
|
|
||||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
|
||||||
addon_info = await self._async_get_addon_info(flasher_manager)
|
|
||||||
new_addon_config = {
|
|
||||||
**addon_info.options,
|
|
||||||
"device": new_settings.device,
|
|
||||||
"flow_control": new_settings.flow_control,
|
|
||||||
}
|
|
||||||
|
|
||||||
_LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config)
|
|
||||||
await self._async_set_addon_config(new_addon_config, flasher_manager)
|
|
||||||
|
|
||||||
return await self.async_step_uninstall_multiprotocol_addon()
|
return await self.async_step_uninstall_multiprotocol_addon()
|
||||||
|
|
||||||
async def async_step_uninstall_multiprotocol_addon(
|
async def async_step_uninstall_multiprotocol_addon(
|
||||||
@@ -821,62 +716,93 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
|||||||
finally:
|
finally:
|
||||||
self.stop_task = None
|
self.stop_task = None
|
||||||
|
|
||||||
return self.async_show_progress_done(next_step_id="start_flasher_addon")
|
return self.async_show_progress_done(next_step_id="install_zigbee_firmware")
|
||||||
|
|
||||||
async def async_step_start_flasher_addon(
|
async def async_step_install_zigbee_firmware(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Start Silicon Labs Flasher add-on."""
|
"""Flash Zigbee firmware directly onto the radio."""
|
||||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
if not self.install_task:
|
||||||
|
|
||||||
if not self.start_task:
|
async def _flash_firmware() -> None:
|
||||||
|
serial_port_settings = await self._async_serial_port_settings()
|
||||||
|
device = serial_port_settings.device
|
||||||
|
|
||||||
async def start_and_wait_until_done() -> None:
|
# For the duration of firmware flashing, hint to other integrations
|
||||||
await flasher_manager.async_start_addon_waiting()
|
# (i.e. ZHA) that the hardware is in use and should not be accessed.
|
||||||
# Now that the addon is running, wait for it to finish
|
async with async_firmware_flashing_context(self.hass, device, DOMAIN):
|
||||||
await flasher_manager.async_wait_until_addon_state(
|
session = async_get_clientsession(self.hass)
|
||||||
AddonState.NOT_RUNNING
|
client = FirmwareUpdateClient(self._firmware_update_url(), session)
|
||||||
)
|
|
||||||
|
|
||||||
self.start_task = self.hass.async_create_task(
|
try:
|
||||||
start_and_wait_until_done(), eager_start=False
|
manifest = await client.async_update_data()
|
||||||
|
fw_manifest = next(
|
||||||
|
fw
|
||||||
|
for fw in manifest.firmwares
|
||||||
|
if fw.filename.startswith(self._zigbee_firmware_type())
|
||||||
|
)
|
||||||
|
fw_data = await client.async_fetch_firmware(fw_manifest)
|
||||||
|
except (
|
||||||
|
StopIteration,
|
||||||
|
TimeoutError,
|
||||||
|
ClientError,
|
||||||
|
ManifestMissing,
|
||||||
|
ValueError,
|
||||||
|
) as err:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"Failed to fetch Zigbee firmware"
|
||||||
|
) from err
|
||||||
|
|
||||||
|
await async_flash_silabs_firmware(
|
||||||
|
hass=self.hass,
|
||||||
|
device=device,
|
||||||
|
fw_data=fw_data,
|
||||||
|
flasher_cls=self._flasher_cls,
|
||||||
|
expected_installed_firmware_type=ApplicationType.EZSP,
|
||||||
|
progress_callback=lambda offset, total: (
|
||||||
|
self.async_update_progress(offset / total)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.install_task = self.hass.async_create_task(
|
||||||
|
_flash_firmware(),
|
||||||
|
"Flash Zigbee firmware",
|
||||||
|
eager_start=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not self.start_task.done():
|
if not self.install_task.done():
|
||||||
return self.async_show_progress(
|
return self.async_show_progress(
|
||||||
step_id="start_flasher_addon",
|
step_id="install_zigbee_firmware",
|
||||||
progress_action="start_flasher_addon",
|
progress_action="install_zigbee_firmware",
|
||||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
description_placeholders={
|
||||||
progress_task=self.start_task,
|
"hardware_name": self._hardware_name(),
|
||||||
|
},
|
||||||
|
progress_task=self.install_task,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.start_task
|
await self.install_task
|
||||||
except (AddonError, AbortFlow) as err:
|
except HomeAssistantError as err:
|
||||||
_LOGGER.error(err)
|
_LOGGER.error("Failed to flash Zigbee firmware: %s", err)
|
||||||
return self.async_show_progress_done(next_step_id="flasher_failed")
|
return self.async_show_progress_done(next_step_id="firmware_flash_failed")
|
||||||
finally:
|
finally:
|
||||||
self.start_task = None
|
self.install_task = None
|
||||||
|
|
||||||
return self.async_show_progress_done(next_step_id="flashing_complete")
|
return self.async_show_progress_done(next_step_id="flashing_complete")
|
||||||
|
|
||||||
async def async_step_flasher_failed(
|
async def async_step_firmware_flash_failed(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Flasher add-on start failed."""
|
"""Firmware flashing failed."""
|
||||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
|
||||||
return self.async_abort(
|
return self.async_abort(
|
||||||
reason="addon_start_failed",
|
reason="fw_install_failed",
|
||||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
description_placeholders={"firmware_name": "Zigbee"},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_flashing_complete(
|
async def async_step_flashing_complete(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Finish flashing and update the config entry."""
|
"""Finish flashing and update the config entry."""
|
||||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
|
||||||
await flasher_manager.async_uninstall_addon_waiting()
|
|
||||||
|
|
||||||
# Finish ZHA migration if needed
|
# Finish ZHA migration if needed
|
||||||
if self._zha_migration_mgr:
|
if self._zha_migration_mgr:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -102,7 +102,9 @@
|
|||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.",
|
"install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.",
|
||||||
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds."
|
"install_zigbee_firmware": "Please wait while Zigbee-only firmware is installed on your {hardware_name}. This can take several minutes.",
|
||||||
|
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds.",
|
||||||
|
"uninstall_multiprotocol_addon": "Please wait while the {addon_name} app is uninstalled."
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"addon_installed_other_device": {
|
"addon_installed_other_device": {
|
||||||
|
|||||||
@@ -37,13 +37,59 @@ from .const import (
|
|||||||
ZIGBEE_FLASHER_ADDON_SLUG,
|
ZIGBEE_FLASHER_ADDON_SLUG,
|
||||||
)
|
)
|
||||||
from .helpers import async_firmware_update_context
|
from .helpers import async_firmware_update_context
|
||||||
from .silabs_multiprotocol_addon import (
|
|
||||||
WaitingAddonManager,
|
|
||||||
get_multiprotocol_addon_manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ADDON_STATE_POLL_INTERVAL = 3
|
||||||
|
ADDON_INFO_POLL_TIMEOUT = 15 * 60
|
||||||
|
|
||||||
|
|
||||||
|
class WaitingAddonManager(AddonManager):
|
||||||
|
"""Addon manager which supports waiting operations for managing an addon."""
|
||||||
|
|
||||||
|
async def async_wait_until_addon_state(self, *states: AddonState) -> None:
|
||||||
|
"""Poll an addon's info until it is in a specific state."""
|
||||||
|
async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
info = await self.async_get_addon_info()
|
||||||
|
except AddonError:
|
||||||
|
info = None
|
||||||
|
|
||||||
|
_LOGGER.debug("Waiting for addon to be in state %s: %s", states, info)
|
||||||
|
|
||||||
|
if info is not None and info.state in states:
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(ADDON_STATE_POLL_INTERVAL)
|
||||||
|
|
||||||
|
async def async_start_addon_waiting(self) -> None:
|
||||||
|
"""Start an add-on."""
|
||||||
|
await self.async_schedule_start_addon()
|
||||||
|
await self.async_wait_until_addon_state(AddonState.RUNNING)
|
||||||
|
|
||||||
|
async def async_install_addon_waiting(self) -> None:
|
||||||
|
"""Install an add-on."""
|
||||||
|
await self.async_schedule_install_addon()
|
||||||
|
await self.async_wait_until_addon_state(
|
||||||
|
AddonState.RUNNING,
|
||||||
|
AddonState.NOT_RUNNING,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_uninstall_addon_waiting(self) -> None:
|
||||||
|
"""Uninstall an add-on."""
|
||||||
|
try:
|
||||||
|
info = await self.async_get_addon_info()
|
||||||
|
except AddonError:
|
||||||
|
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:
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.async_uninstall_addon()
|
||||||
|
await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED)
|
||||||
|
|
||||||
|
|
||||||
class ApplicationType(StrEnum):
|
class ApplicationType(StrEnum):
|
||||||
"""Application type running on a device."""
|
"""Application type running on a device."""
|
||||||
@@ -279,6 +325,11 @@ async def guess_hardware_owners(
|
|||||||
assert otbr_addon_fw_info is not None
|
assert otbr_addon_fw_info is not None
|
||||||
device_guesses[otbr_path].append(otbr_addon_fw_info)
|
device_guesses[otbr_path].append(otbr_addon_fw_info)
|
||||||
|
|
||||||
|
# Lazy import to avoid circular dependency
|
||||||
|
from .silabs_multiprotocol_addon import ( # noqa: PLC0415
|
||||||
|
get_multiprotocol_addon_manager,
|
||||||
|
)
|
||||||
|
|
||||||
multipan_addon_manager = await get_multiprotocol_addon_manager(hass)
|
multipan_addon_manager = await get_multiprotocol_addon_manager(hass)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import os.path
|
|||||||
from homeassistant.components.homeassistant_hardware.coordinator import (
|
from homeassistant.components.homeassistant_hardware.coordinator import (
|
||||||
FirmwareUpdateCoordinator,
|
FirmwareUpdateCoordinator,
|
||||||
)
|
)
|
||||||
|
from homeassistant.components.homeassistant_hardware.repair_helpers import (
|
||||||
|
async_create_multi_pan_migration_issue,
|
||||||
|
async_delete_multi_pan_migration_issue,
|
||||||
|
)
|
||||||
|
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
|
||||||
|
multi_pan_addon_using_device,
|
||||||
|
)
|
||||||
from homeassistant.components.homeassistant_hardware.util import guess_firmware_info
|
from homeassistant.components.homeassistant_hardware.util import guess_firmware_info
|
||||||
from homeassistant.components.usb import (
|
from homeassistant.components.usb import (
|
||||||
USBDevice,
|
USBDevice,
|
||||||
@@ -92,6 +99,16 @@ async def async_setup_entry(
|
|||||||
translation_key="device_disconnected",
|
translation_key="device_disconnected",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
uses_multi_pan = await multi_pan_addon_using_device(hass, device_path)
|
||||||
|
except HomeAssistantError as err:
|
||||||
|
raise ConfigEntryNotReady from err
|
||||||
|
|
||||||
|
if uses_multi_pan:
|
||||||
|
async_create_multi_pan_migration_issue(hass, DOMAIN, entry)
|
||||||
|
else:
|
||||||
|
async_delete_multi_pan_migration_issue(hass, DOMAIN, entry)
|
||||||
|
|
||||||
# Create and store the firmware update coordinator in runtime_data
|
# Create and store the firmware update coordinator in runtime_data
|
||||||
session = async_get_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
coordinator = FirmwareUpdateCoordinator(
|
coordinator = FirmwareUpdateCoordinator(
|
||||||
|
|||||||
@@ -248,6 +248,19 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
|
|||||||
"""Return the name of the hardware."""
|
"""Return the name of the hardware."""
|
||||||
return self._hw_variant.full_name
|
return self._hw_variant.full_name
|
||||||
|
|
||||||
|
def _firmware_update_url(self) -> str:
|
||||||
|
"""Return the firmware update manifest URL."""
|
||||||
|
return NABU_CASA_FIRMWARE_RELEASES_URL
|
||||||
|
|
||||||
|
def _zigbee_firmware_type(self) -> str:
|
||||||
|
"""Return the zigbee firmware type identifier."""
|
||||||
|
return "skyconnect_zigbee_ncp"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _flasher_cls(self) -> type:
|
||||||
|
"""Return the hardware-specific flasher class."""
|
||||||
|
return Zbt1Flasher # type: ignore[no-any-return]
|
||||||
|
|
||||||
async def async_step_flashing_complete(
|
async def async_step_flashing_complete(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Repairs for the Home Assistant SkyConnect integration."""
|
||||||
|
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from homeassistant.components.homeassistant_hardware.repair_helpers import (
|
||||||
|
ISSUE_MULTI_PAN_MIGRATION,
|
||||||
|
MultiPanMigrationRepairFlow,
|
||||||
|
)
|
||||||
|
from homeassistant.components.repairs import (
|
||||||
|
ConfirmRepairFlow,
|
||||||
|
RepairsFlow,
|
||||||
|
RepairsFlowResult,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .config_flow import HomeAssistantSkyConnectMultiPanOptionsFlowHandler
|
||||||
|
|
||||||
|
|
||||||
|
class SkyConnectMultiPanMigrationRepairFlow(
|
||||||
|
MultiPanMigrationRepairFlow, HomeAssistantSkyConnectMultiPanOptionsFlowHandler
|
||||||
|
):
|
||||||
|
"""Multi-PAN migration repair flow for Home Assistant SkyConnect."""
|
||||||
|
|
||||||
|
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||||
|
"""Initialize the repair flow."""
|
||||||
|
HomeAssistantSkyConnectMultiPanOptionsFlowHandler.__init__(self, config_entry)
|
||||||
|
self._repair_config_entry = config_entry
|
||||||
|
|
||||||
|
async def async_step_init( # type: ignore[override]
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> RepairsFlowResult:
|
||||||
|
"""Jump straight into the uninstall step."""
|
||||||
|
return await self._async_step_start_migration()
|
||||||
|
|
||||||
|
|
||||||
|
async def async_create_fix_flow(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
issue_id: str,
|
||||||
|
data: dict[str, str | int | float | None] | None,
|
||||||
|
) -> RepairsFlow:
|
||||||
|
"""Create a fix flow for a SkyConnect repair issue."""
|
||||||
|
if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None:
|
||||||
|
entry_id = cast(str, data["entry_id"])
|
||||||
|
if (entry := hass.config_entries.async_get_entry(entry_id)) is not None:
|
||||||
|
return SkyConnectMultiPanMigrationRepairFlow(entry)
|
||||||
|
|
||||||
|
return ConfirmRepairFlow()
|
||||||
@@ -106,6 +106,37 @@
|
|||||||
"message": "The device is not plugged in"
|
"message": "The device is not plugged in"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"multi_pan_migration": {
|
||||||
|
"fix_flow": {
|
||||||
|
"abort": {
|
||||||
|
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
|
||||||
|
"addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
|
||||||
|
"addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
|
||||||
|
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
|
||||||
|
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
|
||||||
|
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]",
|
||||||
|
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
|
||||||
|
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||||
|
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
|
||||||
|
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"uninstall_addon": {
|
||||||
|
"data": {
|
||||||
|
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
|
||||||
|
},
|
||||||
|
"description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restore a backup.",
|
||||||
|
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Multiprotocol support is deprecated"
|
||||||
|
}
|
||||||
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"abort": {
|
"abort": {
|
||||||
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
|
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
|
||||||
@@ -130,8 +161,10 @@
|
|||||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||||
|
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
|
||||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]",
|
||||||
|
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"addon_installed_other_device": {
|
"addon_installed_other_device": {
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ from homeassistant.components.hassio import HassioNotReadyError, get_os_info
|
|||||||
from homeassistant.components.homeassistant_hardware.coordinator import (
|
from homeassistant.components.homeassistant_hardware.coordinator import (
|
||||||
FirmwareUpdateCoordinator,
|
FirmwareUpdateCoordinator,
|
||||||
)
|
)
|
||||||
|
from homeassistant.components.homeassistant_hardware.repair_helpers import (
|
||||||
|
async_create_multi_pan_migration_issue,
|
||||||
|
async_delete_multi_pan_migration_issue,
|
||||||
|
)
|
||||||
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
|
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
|
||||||
check_multi_pan_addon,
|
check_multi_pan_addon,
|
||||||
|
multi_pan_addon_using_device,
|
||||||
)
|
)
|
||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
@@ -27,6 +32,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|||||||
from homeassistant.helpers.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
FIRMWARE,
|
FIRMWARE,
|
||||||
FIRMWARE_VERSION,
|
FIRMWARE_VERSION,
|
||||||
MANUFACTURER,
|
MANUFACTURER,
|
||||||
@@ -77,6 +83,16 @@ async def async_setup_entry(
|
|||||||
except HomeAssistantError as err:
|
except HomeAssistantError as err:
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryNotReady from err
|
||||||
|
|
||||||
|
try:
|
||||||
|
multipan_using_device = await multi_pan_addon_using_device(hass, RADIO_DEVICE)
|
||||||
|
except HomeAssistantError as err:
|
||||||
|
raise ConfigEntryNotReady from err
|
||||||
|
|
||||||
|
if multipan_using_device:
|
||||||
|
async_create_multi_pan_migration_issue(hass, DOMAIN, entry)
|
||||||
|
else:
|
||||||
|
async_delete_multi_pan_migration_issue(hass, DOMAIN, entry)
|
||||||
|
|
||||||
if firmware is ApplicationType.EZSP:
|
if firmware is ApplicationType.EZSP:
|
||||||
discovery_flow.async_create_flow(
|
discovery_flow.async_create_flow(
|
||||||
hass,
|
hass,
|
||||||
|
|||||||
@@ -319,6 +319,19 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler(
|
|||||||
"""Return the name of the hardware."""
|
"""Return the name of the hardware."""
|
||||||
return BOARD_NAME
|
return BOARD_NAME
|
||||||
|
|
||||||
|
def _firmware_update_url(self) -> str:
|
||||||
|
"""Return the firmware update manifest URL."""
|
||||||
|
return NABU_CASA_FIRMWARE_RELEASES_URL
|
||||||
|
|
||||||
|
def _zigbee_firmware_type(self) -> str:
|
||||||
|
"""Return the zigbee firmware type identifier."""
|
||||||
|
return "yellow_zigbee_ncp"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _flasher_cls(self) -> type:
|
||||||
|
"""Return the hardware-specific flasher class."""
|
||||||
|
return YellowFlasher # type: ignore[no-any-return]
|
||||||
|
|
||||||
async def async_step_flashing_complete(
|
async def async_step_flashing_complete(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Repairs for the Home Assistant Yellow integration."""
|
||||||
|
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from homeassistant.components.homeassistant_hardware.repair_helpers import (
|
||||||
|
ISSUE_MULTI_PAN_MIGRATION,
|
||||||
|
MultiPanMigrationRepairFlow,
|
||||||
|
)
|
||||||
|
from homeassistant.components.repairs import (
|
||||||
|
ConfirmRepairFlow,
|
||||||
|
RepairsFlow,
|
||||||
|
RepairsFlowResult,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .config_flow import HomeAssistantYellowMultiPanOptionsFlowHandler
|
||||||
|
|
||||||
|
|
||||||
|
class YellowMultiPanMigrationRepairFlow(
|
||||||
|
MultiPanMigrationRepairFlow, HomeAssistantYellowMultiPanOptionsFlowHandler
|
||||||
|
):
|
||||||
|
"""Multi-PAN migration repair flow for Home Assistant Yellow."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||||
|
"""Initialize the repair flow."""
|
||||||
|
HomeAssistantYellowMultiPanOptionsFlowHandler.__init__(self, hass, config_entry)
|
||||||
|
self._repair_config_entry = config_entry
|
||||||
|
|
||||||
|
async def async_step_main_menu( # type: ignore[override]
|
||||||
|
self, _: None = None
|
||||||
|
) -> RepairsFlowResult:
|
||||||
|
"""Jump straight into the uninstall step."""
|
||||||
|
return await self._async_step_start_migration()
|
||||||
|
|
||||||
|
|
||||||
|
async def async_create_fix_flow(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
issue_id: str,
|
||||||
|
data: dict[str, str | int | float | None] | None,
|
||||||
|
) -> RepairsFlow:
|
||||||
|
"""Create a fix flow for a Yellow repair issue."""
|
||||||
|
if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None:
|
||||||
|
entry_id = cast(str, data["entry_id"])
|
||||||
|
if (entry := hass.config_entries.async_get_entry(entry_id)) is not None:
|
||||||
|
return YellowMultiPanMigrationRepairFlow(hass, entry)
|
||||||
|
|
||||||
|
return ConfirmRepairFlow()
|
||||||
@@ -11,6 +11,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"multi_pan_migration": {
|
||||||
|
"fix_flow": {
|
||||||
|
"abort": {
|
||||||
|
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
|
||||||
|
"addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
|
||||||
|
"addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
|
||||||
|
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
|
||||||
|
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
|
||||||
|
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]",
|
||||||
|
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
|
||||||
|
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||||
|
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
|
||||||
|
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"uninstall_addon": {
|
||||||
|
"data": {
|
||||||
|
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
|
||||||
|
},
|
||||||
|
"description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restore a backup.",
|
||||||
|
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Multiprotocol support is deprecated"
|
||||||
|
}
|
||||||
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"abort": {
|
"abort": {
|
||||||
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
|
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
|
||||||
@@ -37,8 +68,10 @@
|
|||||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||||
|
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
|
||||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]",
|
||||||
|
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"addon_installed_other_device": {
|
"addon_installed_other_device": {
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ OPEN_CLOSE_ATTRIBUTES = [
|
|||||||
AttributeType.UP_DOWN,
|
AttributeType.UP_DOWN,
|
||||||
]
|
]
|
||||||
POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION]
|
POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION]
|
||||||
|
COVER_DEVICE_PROFILES = {
|
||||||
|
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
|
||||||
|
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
|
||||||
|
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
|
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
|
||||||
@@ -69,12 +74,6 @@ def get_cover_features(
|
|||||||
|
|
||||||
def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
|
def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
|
||||||
"""Determine the device class a homee node based on the node profile."""
|
"""Determine the device class a homee node based on the node profile."""
|
||||||
COVER_DEVICE_PROFILES = {
|
|
||||||
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
|
|
||||||
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
|
|
||||||
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
|
|
||||||
}
|
|
||||||
|
|
||||||
return COVER_DEVICE_PROFILES.get(node.profile)
|
return COVER_DEVICE_PROFILES.get(node.profile)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from homeassistant.helpers import config_validation as cv
|
|||||||
from homeassistant.helpers.automation import DomainSpec
|
from homeassistant.helpers.automation import DomainSpec
|
||||||
from homeassistant.helpers.entity import get_supported_features
|
from homeassistant.helpers.entity import get_supported_features
|
||||||
from homeassistant.helpers.trigger import (
|
from homeassistant.helpers.trigger import (
|
||||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
|
||||||
EntityTargetStateTriggerBase,
|
EntityTargetStateTriggerBase,
|
||||||
Trigger,
|
Trigger,
|
||||||
TriggerConfig,
|
TriggerConfig,
|
||||||
@@ -18,7 +18,7 @@ from homeassistant.helpers.trigger import (
|
|||||||
|
|
||||||
from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature
|
from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature
|
||||||
|
|
||||||
MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
|
MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_OPTIONS): {
|
vol.Required(CONF_OPTIONS): {
|
||||||
vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]),
|
vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
fields:
|
fields:
|
||||||
behavior: &trigger_behavior
|
behavior: &trigger_behavior
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.trigger_common_fields:
|
.trigger_common_fields:
|
||||||
behavior: &trigger_behavior
|
behavior: &trigger_behavior
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ CONF_INFO = "info"
|
|||||||
CONF_INVERTING = "inverting"
|
CONF_INVERTING = "inverting"
|
||||||
CONF_LIGHT = "light"
|
CONF_LIGHT = "light"
|
||||||
CONF_NODE = "node"
|
CONF_NODE = "node"
|
||||||
CONF_NOTE = "note"
|
|
||||||
CONF_OFF_ID = "off_id"
|
CONF_OFF_ID = "off_id"
|
||||||
CONF_ON_ID = "on_id"
|
CONF_ON_ID = "on_id"
|
||||||
CONF_POSITION = "position"
|
CONF_POSITION = "position"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
|
|||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_ID,
|
CONF_ID,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
|
CONF_NOTE,
|
||||||
CONF_PASSWORD,
|
CONF_PASSWORD,
|
||||||
CONF_TYPE,
|
CONF_TYPE,
|
||||||
CONF_UNIT_OF_MEASUREMENT,
|
CONF_UNIT_OF_MEASUREMENT,
|
||||||
@@ -25,7 +26,6 @@ from .const import (
|
|||||||
CONF_INFO,
|
CONF_INFO,
|
||||||
CONF_INVERTING,
|
CONF_INVERTING,
|
||||||
CONF_LIGHT,
|
CONF_LIGHT,
|
||||||
CONF_NOTE,
|
|
||||||
CONF_OFF_ID,
|
CONF_OFF_ID,
|
||||||
CONF_ON_ID,
|
CONF_ON_ID,
|
||||||
CONF_POSITION,
|
CONF_POSITION,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.trigger_common_fields: &trigger_common_fields
|
.trigger_common_fields: &trigger_common_fields
|
||||||
behavior: &trigger_behavior
|
behavior: &trigger_behavior
|
||||||
required: true
|
required: true
|
||||||
default: any
|
default: each
|
||||||
selector:
|
selector:
|
||||||
automation_behavior:
|
automation_behavior:
|
||||||
mode: trigger
|
mode: trigger
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any, override
|
||||||
|
|
||||||
from incomfortclient import Heater as InComfortHeater
|
from incomfortclient import Heater as InComfortHeater
|
||||||
|
|
||||||
@@ -97,11 +97,13 @@ class IncomfortBinarySensor(IncomfortBoilerEntity, BinarySensorEntity):
|
|||||||
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
|
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@override
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return the status of the sensor."""
|
"""Return the status of the sensor."""
|
||||||
return bool(self._heater.status[self.entity_description.value_key])
|
return bool(self._heater.status[self.entity_description.value_key])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@override
|
||||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||||
"""Return the device state attributes."""
|
"""Return the device state attributes."""
|
||||||
if (attributes_fn := self.entity_description.extra_state_attributes_fn) is None:
|
if (attributes_fn := self.entity_description.extra_state_attributes_fn) is None:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway."""
|
"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, override
|
||||||
|
|
||||||
from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom
|
from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom
|
||||||
|
|
||||||
@@ -76,16 +76,19 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@override
|
||||||
def extra_state_attributes(self) -> dict[str, Any]:
|
def extra_state_attributes(self) -> dict[str, Any]:
|
||||||
"""Return the device state attributes."""
|
"""Return the device state attributes."""
|
||||||
return {"status": self._room.status}
|
return {"status": self._room.status}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@override
|
||||||
def current_temperature(self) -> float | None:
|
def current_temperature(self) -> float | None:
|
||||||
"""Return the current temperature."""
|
"""Return the current temperature."""
|
||||||
return self._room.room_temp
|
return self._room.room_temp
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@override
|
||||||
def hvac_action(self) -> HVACAction | None:
|
def hvac_action(self) -> HVACAction | None:
|
||||||
"""Return the actual current HVAC action."""
|
"""Return the actual current HVAC action."""
|
||||||
if self._heater.is_burning and self._heater.is_pumping:
|
if self._heater.is_burning and self._heater.is_pumping:
|
||||||
@@ -93,6 +96,7 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
|
|||||||
return HVACAction.IDLE
|
return HVACAction.IDLE
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@override
|
||||||
def target_temperature(self) -> float | None:
|
def target_temperature(self) -> float | None:
|
||||||
"""Return the (override)temperature we try to reach.
|
"""Return the (override)temperature we try to reach.
|
||||||
|
|
||||||
@@ -106,11 +110,13 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
|
|||||||
return self._room.setpoint
|
return self._room.setpoint
|
||||||
return self._room.override or self._room.setpoint
|
return self._room.override or self._room.setpoint
|
||||||
|
|
||||||
|
@override
|
||||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||||
"""Set a new target temperature for this zone."""
|
"""Set a new target temperature for this zone."""
|
||||||
temperature: float = kwargs[ATTR_TEMPERATURE]
|
temperature: float = kwargs[ATTR_TEMPERATURE]
|
||||||
await self._room.set_override(temperature)
|
await self._room.set_override(temperature)
|
||||||
await self.coordinator.async_refresh()
|
await self.coordinator.async_refresh()
|
||||||
|
|
||||||
|
@override
|
||||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||||
"""Set new target hvac mode."""
|
"""Set new target hvac mode."""
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, override
|
||||||
|
|
||||||
from incomfortclient import InvalidGateway, InvalidHeaterList
|
from incomfortclient import InvalidGateway, InvalidHeaterList
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -100,6 +100,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
_discovered_host: str
|
_discovered_host: str
|
||||||
|
|
||||||
|
@override
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(
|
def async_get_options_flow(
|
||||||
@@ -108,6 +109,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Get the options flow for this handler."""
|
"""Get the options flow for this handler."""
|
||||||
return InComfortOptionsFlowHandler()
|
return InComfortOptionsFlowHandler()
|
||||||
|
|
||||||
|
@override
|
||||||
async def async_step_dhcp(
|
async def async_step_dhcp(
|
||||||
self, discovery_info: DhcpServiceInfo
|
self, discovery_info: DhcpServiceInfo
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@@ -169,6 +171,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
description_placeholders={CONF_HOST: self._discovered_host},
|
description_placeholders={CONF_HOST: self._discovered_host},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, override
|
||||||
|
|
||||||
from aiohttp import ClientResponseError
|
from aiohttp import ClientResponseError
|
||||||
from incomfortclient import (
|
from incomfortclient import (
|
||||||
@@ -74,6 +74,7 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
|
|||||||
)
|
)
|
||||||
self.incomfort_data = incomfort_data
|
self.incomfort_data = incomfort_data
|
||||||
|
|
||||||
|
@override
|
||||||
async def _async_update_data(self) -> InComfortData:
|
async def _async_update_data(self) -> InComfortData:
|
||||||
"""Fetch data from API endpoint."""
|
"""Fetch data from API endpoint."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway."""
|
"""Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any, override
|
||||||
|
|
||||||
from incomfortclient import Heater as InComfortHeater
|
from incomfortclient import Heater as InComfortHeater
|
||||||
|
|
||||||
@@ -104,11 +104,13 @@ class IncomfortSensor(IncomfortBoilerEntity, SensorEntity):
|
|||||||
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
|
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@override
|
||||||
def native_value(self) -> StateType:
|
def native_value(self) -> StateType:
|
||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor."""
|
||||||
return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return]
|
return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@override
|
||||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||||
"""Return the device state attributes."""
|
"""Return the device state attributes."""
|
||||||
if (extra_key := self.entity_description.extra_key) is None:
|
if (extra_key := self.entity_description.extra_key) is None:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway."""
|
"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, override
|
||||||
|
|
||||||
from incomfortclient import Heater as InComfortHeater
|
from incomfortclient import Heater as InComfortHeater
|
||||||
|
|
||||||
@@ -49,11 +49,13 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
|
|||||||
self._attr_unique_id = heater.serial_no
|
self._attr_unique_id = heater.serial_no
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@override
|
||||||
def extra_state_attributes(self) -> dict[str, Any]:
|
def extra_state_attributes(self) -> dict[str, Any]:
|
||||||
"""Return the device state attributes."""
|
"""Return the device state attributes."""
|
||||||
return {k: v for k, v in self._heater.status.items() if k in HEATER_ATTRS}
|
return {k: v for k, v in self._heater.status.items() if k in HEATER_ATTRS}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@override
|
||||||
def current_temperature(self) -> float | None:
|
def current_temperature(self) -> float | None:
|
||||||
"""Return the current temperature."""
|
"""Return the current temperature."""
|
||||||
if self._heater.is_tapping:
|
if self._heater.is_tapping:
|
||||||
@@ -67,6 +69,7 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
|
|||||||
return max(self._heater.heater_temp, self._heater.tap_temp)
|
return max(self._heater.heater_temp, self._heater.tap_temp)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@override
|
||||||
def current_operation(self) -> str | None:
|
def current_operation(self) -> str | None:
|
||||||
"""Return the current operation mode."""
|
"""Return the current operation mode."""
|
||||||
return self._heater.display_text
|
return self._heater.display_text
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
|||||||
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
|
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
|
||||||
IndevoltSystem.BYPASS_POWER,
|
IndevoltSystem.BYPASS_POWER,
|
||||||
IndevoltSystem.BYPASS_INPUT_ENERGY,
|
IndevoltSystem.BYPASS_INPUT_ENERGY,
|
||||||
|
IndevoltBattery.RATED_CAPACITY,
|
||||||
IndevoltBattery.DAILY_CHARGING_ENERGY,
|
IndevoltBattery.DAILY_CHARGING_ENERGY,
|
||||||
IndevoltBattery.DAILY_DISCHARGING_ENERGY,
|
IndevoltBattery.DAILY_DISCHARGING_ENERGY,
|
||||||
IndevoltBattery.TOTAL_CHARGING_ENERGY,
|
IndevoltBattery.TOTAL_CHARGING_ENERGY,
|
||||||
@@ -78,7 +79,7 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
|||||||
IndevoltSolar.DC_INPUT_POWER_2,
|
IndevoltSolar.DC_INPUT_POWER_2,
|
||||||
IndevoltSolar.DC_INPUT_POWER_3,
|
IndevoltSolar.DC_INPUT_POWER_3,
|
||||||
IndevoltSolar.DC_INPUT_POWER_4,
|
IndevoltSolar.DC_INPUT_POWER_4,
|
||||||
IndevoltBattery.RATED_CAPACITY_GEN2,
|
IndevoltBattery.RATED_CAPACITY,
|
||||||
IndevoltSystem.BYPASS_POWER,
|
IndevoltSystem.BYPASS_POWER,
|
||||||
IndevoltSystem.TOTAL_OUTPUT_ENERGY,
|
IndevoltSystem.TOTAL_OUTPUT_ENERGY,
|
||||||
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
|
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
|
||||||
@@ -134,6 +135,12 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
|||||||
IndevoltBattery.PACK_3_CURRENT,
|
IndevoltBattery.PACK_3_CURRENT,
|
||||||
IndevoltBattery.PACK_4_CURRENT,
|
IndevoltBattery.PACK_4_CURRENT,
|
||||||
IndevoltBattery.PACK_5_CURRENT,
|
IndevoltBattery.PACK_5_CURRENT,
|
||||||
|
IndevoltBattery.MAIN_CYCLES,
|
||||||
|
IndevoltBattery.PACK_1_CYCLES,
|
||||||
|
IndevoltBattery.PACK_2_CYCLES,
|
||||||
|
IndevoltBattery.PACK_3_CYCLES,
|
||||||
|
IndevoltBattery.PACK_4_CYCLES,
|
||||||
|
IndevoltBattery.PACK_5_CYCLES,
|
||||||
IndevoltConfig.READ_BYPASS,
|
IndevoltConfig.READ_BYPASS,
|
||||||
IndevoltConfig.READ_GRID_CHARGING,
|
IndevoltConfig.READ_GRID_CHARGING,
|
||||||
IndevoltConfig.READ_LIGHT,
|
IndevoltConfig.READ_LIGHT,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Home Assistant integration for Indevolt device."""
|
"""Home Assistant integration for Indevolt device."""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ from .const import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
SCAN_BATCH_SIZE: Final = 50
|
||||||
SCAN_INTERVAL: Final = 30
|
SCAN_INTERVAL: Final = 30
|
||||||
|
|
||||||
type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator]
|
type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator]
|
||||||
@@ -86,10 +88,13 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, Any]:
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
"""Fetch raw JSON data from the device."""
|
"""Fetch raw JSON data from the device."""
|
||||||
|
data: dict[str, Any] = {}
|
||||||
sensor_keys = SENSOR_KEYS[self.generation]
|
sensor_keys = SENSOR_KEYS[self.generation]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await self.api.fetch_data(sensor_keys)
|
for chunk in itertools.batched(sensor_keys, SCAN_BATCH_SIZE, strict=False):
|
||||||
|
data.update(await self.api.fetch_data(list(chunk)))
|
||||||
|
|
||||||
except (ClientError, OSError) as err:
|
except (ClientError, OSError) as err:
|
||||||
raise UpdateFailed(
|
raise UpdateFailed(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
@@ -97,6 +102,9 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
translation_placeholders={"error": str(err)},
|
translation_placeholders={"error": str(err)},
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
|
||||||
async def async_push_data(self, sensor_key: str, value: Any) -> bool:
|
async def async_push_data(self, sensor_key: str, value: Any) -> bool:
|
||||||
"""Push/write data values to given key on the device."""
|
"""Push/write data values to given key on the device."""
|
||||||
return await self.api.set_data(sensor_key, value)
|
return await self.api.set_data(sensor_key, value)
|
||||||
|
|||||||
@@ -73,12 +73,10 @@ SENSORS: Final = (
|
|||||||
device_class=SensorDeviceClass.ENUM,
|
device_class=SensorDeviceClass.ENUM,
|
||||||
),
|
),
|
||||||
IndevoltSensorEntityDescription(
|
IndevoltSensorEntityDescription(
|
||||||
key=IndevoltBattery.RATED_CAPACITY_GEN2,
|
key=IndevoltBattery.RATED_CAPACITY,
|
||||||
generation=(2,),
|
|
||||||
translation_key="rated_capacity",
|
translation_key="rated_capacity",
|
||||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||||
device_class=SensorDeviceClass.ENERGY,
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
||||||
),
|
),
|
||||||
IndevoltSensorEntityDescription(
|
IndevoltSensorEntityDescription(
|
||||||
key=IndevoltConfig.READ_DISCHARGE_LIMIT,
|
key=IndevoltConfig.READ_DISCHARGE_LIMIT,
|
||||||
@@ -132,7 +130,7 @@ SENSORS: Final = (
|
|||||||
IndevoltSensorEntityDescription(
|
IndevoltSensorEntityDescription(
|
||||||
key=IndevoltBattery.GEN_2_CYCLE_COUNT,
|
key=IndevoltBattery.GEN_2_CYCLE_COUNT,
|
||||||
generation=(2,),
|
generation=(2,),
|
||||||
translation_key="cycle_count",
|
translation_key="equivalent_full_cycles",
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
@@ -794,9 +792,58 @@ SENSORS: Final = (
|
|||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
|
# Battery Pack Cycles
|
||||||
|
IndevoltSensorEntityDescription(
|
||||||
|
key=IndevoltBattery.MAIN_CYCLES,
|
||||||
|
generation=(2,),
|
||||||
|
translation_key="main_cycles",
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
IndevoltSensorEntityDescription(
|
||||||
|
key=IndevoltBattery.PACK_1_CYCLES,
|
||||||
|
generation=(2,),
|
||||||
|
translation_key="battery_pack_1_cycles",
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
IndevoltSensorEntityDescription(
|
||||||
|
key=IndevoltBattery.PACK_2_CYCLES,
|
||||||
|
generation=(2,),
|
||||||
|
translation_key="battery_pack_2_cycles",
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
IndevoltSensorEntityDescription(
|
||||||
|
key=IndevoltBattery.PACK_3_CYCLES,
|
||||||
|
generation=(2,),
|
||||||
|
translation_key="battery_pack_3_cycles",
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
IndevoltSensorEntityDescription(
|
||||||
|
key=IndevoltBattery.PACK_4_CYCLES,
|
||||||
|
generation=(2,),
|
||||||
|
translation_key="battery_pack_4_cycles",
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
IndevoltSensorEntityDescription(
|
||||||
|
key=IndevoltBattery.PACK_5_CYCLES,
|
||||||
|
generation=(2,),
|
||||||
|
translation_key="battery_pack_5_cycles",
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sensors per battery pack (SN, SOC, Temperature, MOS Temperature, Voltage, Current)
|
# Sensors per battery pack (SN, SOC, Temperature, MOS Temperature, Voltage, Current, Cycles)
|
||||||
BATTERY_PACK_SENSOR_KEYS = [
|
BATTERY_PACK_SENSOR_KEYS = [
|
||||||
(
|
(
|
||||||
IndevoltBattery.PACK_1_SERIAL_NUMBER,
|
IndevoltBattery.PACK_1_SERIAL_NUMBER,
|
||||||
@@ -805,6 +852,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
|||||||
IndevoltBattery.PACK_1_MOS_TEMPERATURE,
|
IndevoltBattery.PACK_1_MOS_TEMPERATURE,
|
||||||
IndevoltBattery.PACK_1_VOLTAGE,
|
IndevoltBattery.PACK_1_VOLTAGE,
|
||||||
IndevoltBattery.PACK_1_CURRENT,
|
IndevoltBattery.PACK_1_CURRENT,
|
||||||
|
IndevoltBattery.PACK_1_CYCLES,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
IndevoltBattery.PACK_2_SERIAL_NUMBER,
|
IndevoltBattery.PACK_2_SERIAL_NUMBER,
|
||||||
@@ -813,6 +861,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
|||||||
IndevoltBattery.PACK_2_MOS_TEMPERATURE,
|
IndevoltBattery.PACK_2_MOS_TEMPERATURE,
|
||||||
IndevoltBattery.PACK_2_VOLTAGE,
|
IndevoltBattery.PACK_2_VOLTAGE,
|
||||||
IndevoltBattery.PACK_2_CURRENT,
|
IndevoltBattery.PACK_2_CURRENT,
|
||||||
|
IndevoltBattery.PACK_2_CYCLES,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
IndevoltBattery.PACK_3_SERIAL_NUMBER,
|
IndevoltBattery.PACK_3_SERIAL_NUMBER,
|
||||||
@@ -821,6 +870,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
|||||||
IndevoltBattery.PACK_3_MOS_TEMPERATURE,
|
IndevoltBattery.PACK_3_MOS_TEMPERATURE,
|
||||||
IndevoltBattery.PACK_3_VOLTAGE,
|
IndevoltBattery.PACK_3_VOLTAGE,
|
||||||
IndevoltBattery.PACK_3_CURRENT,
|
IndevoltBattery.PACK_3_CURRENT,
|
||||||
|
IndevoltBattery.PACK_3_CYCLES,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
IndevoltBattery.PACK_4_SERIAL_NUMBER,
|
IndevoltBattery.PACK_4_SERIAL_NUMBER,
|
||||||
@@ -829,6 +879,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
|||||||
IndevoltBattery.PACK_4_MOS_TEMPERATURE,
|
IndevoltBattery.PACK_4_MOS_TEMPERATURE,
|
||||||
IndevoltBattery.PACK_4_VOLTAGE,
|
IndevoltBattery.PACK_4_VOLTAGE,
|
||||||
IndevoltBattery.PACK_4_CURRENT,
|
IndevoltBattery.PACK_4_CURRENT,
|
||||||
|
IndevoltBattery.PACK_4_CYCLES,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
IndevoltBattery.PACK_5_SERIAL_NUMBER,
|
IndevoltBattery.PACK_5_SERIAL_NUMBER,
|
||||||
@@ -837,6 +888,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
|||||||
IndevoltBattery.PACK_5_MOS_TEMPERATURE,
|
IndevoltBattery.PACK_5_MOS_TEMPERATURE,
|
||||||
IndevoltBattery.PACK_5_VOLTAGE,
|
IndevoltBattery.PACK_5_VOLTAGE,
|
||||||
IndevoltBattery.PACK_5_CURRENT,
|
IndevoltBattery.PACK_5_CURRENT,
|
||||||
|
IndevoltBattery.PACK_5_CYCLES,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,9 @@
|
|||||||
"battery_pack_1_current": {
|
"battery_pack_1_current": {
|
||||||
"name": "Battery pack 1 current"
|
"name": "Battery pack 1 current"
|
||||||
},
|
},
|
||||||
|
"battery_pack_1_cycles": {
|
||||||
|
"name": "Battery pack 1 cycle count"
|
||||||
|
},
|
||||||
"battery_pack_1_mos_temperature": {
|
"battery_pack_1_mos_temperature": {
|
||||||
"name": "Battery pack 1 MOS temperature"
|
"name": "Battery pack 1 MOS temperature"
|
||||||
},
|
},
|
||||||
@@ -136,6 +139,9 @@
|
|||||||
"battery_pack_2_current": {
|
"battery_pack_2_current": {
|
||||||
"name": "Battery pack 2 current"
|
"name": "Battery pack 2 current"
|
||||||
},
|
},
|
||||||
|
"battery_pack_2_cycles": {
|
||||||
|
"name": "Battery pack 2 cycle count"
|
||||||
|
},
|
||||||
"battery_pack_2_mos_temperature": {
|
"battery_pack_2_mos_temperature": {
|
||||||
"name": "Battery pack 2 MOS temperature"
|
"name": "Battery pack 2 MOS temperature"
|
||||||
},
|
},
|
||||||
@@ -154,6 +160,9 @@
|
|||||||
"battery_pack_3_current": {
|
"battery_pack_3_current": {
|
||||||
"name": "Battery pack 3 current"
|
"name": "Battery pack 3 current"
|
||||||
},
|
},
|
||||||
|
"battery_pack_3_cycles": {
|
||||||
|
"name": "Battery pack 3 cycle count"
|
||||||
|
},
|
||||||
"battery_pack_3_mos_temperature": {
|
"battery_pack_3_mos_temperature": {
|
||||||
"name": "Battery pack 3 MOS temperature"
|
"name": "Battery pack 3 MOS temperature"
|
||||||
},
|
},
|
||||||
@@ -172,6 +181,9 @@
|
|||||||
"battery_pack_4_current": {
|
"battery_pack_4_current": {
|
||||||
"name": "Battery pack 4 current"
|
"name": "Battery pack 4 current"
|
||||||
},
|
},
|
||||||
|
"battery_pack_4_cycles": {
|
||||||
|
"name": "Battery pack 4 cycle count"
|
||||||
|
},
|
||||||
"battery_pack_4_mos_temperature": {
|
"battery_pack_4_mos_temperature": {
|
||||||
"name": "Battery pack 4 MOS temperature"
|
"name": "Battery pack 4 MOS temperature"
|
||||||
},
|
},
|
||||||
@@ -190,6 +202,9 @@
|
|||||||
"battery_pack_5_current": {
|
"battery_pack_5_current": {
|
||||||
"name": "Battery pack 5 current"
|
"name": "Battery pack 5 current"
|
||||||
},
|
},
|
||||||
|
"battery_pack_5_cycles": {
|
||||||
|
"name": "Battery pack 5 cycle count"
|
||||||
|
},
|
||||||
"battery_pack_5_mos_temperature": {
|
"battery_pack_5_mos_temperature": {
|
||||||
"name": "Battery pack 5 MOS temperature"
|
"name": "Battery pack 5 MOS temperature"
|
||||||
},
|
},
|
||||||
@@ -226,9 +241,6 @@
|
|||||||
"cumulative_production": {
|
"cumulative_production": {
|
||||||
"name": "Cumulative production"
|
"name": "Cumulative production"
|
||||||
},
|
},
|
||||||
"cycle_count": {
|
|
||||||
"name": "Cycle count"
|
|
||||||
},
|
|
||||||
"daily_production": {
|
"daily_production": {
|
||||||
"name": "Daily production"
|
"name": "Daily production"
|
||||||
},
|
},
|
||||||
@@ -283,6 +295,9 @@
|
|||||||
"self_consumed_prioritized": "Self-consumed prioritized"
|
"self_consumed_prioritized": "Self-consumed prioritized"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"equivalent_full_cycles": {
|
||||||
|
"name": "Equivalent full cycles"
|
||||||
|
},
|
||||||
"grid_frequency": {
|
"grid_frequency": {
|
||||||
"name": "Grid frequency"
|
"name": "Grid frequency"
|
||||||
},
|
},
|
||||||
@@ -295,6 +310,9 @@
|
|||||||
"main_current": {
|
"main_current": {
|
||||||
"name": "Main current"
|
"name": "Main current"
|
||||||
},
|
},
|
||||||
|
"main_cycles": {
|
||||||
|
"name": "Main cycle count"
|
||||||
|
},
|
||||||
"main_mos_temperature": {
|
"main_mos_temperature": {
|
||||||
"name": "Main MOS temperature"
|
"name": "Main MOS temperature"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
||||||
"integration_type": "entity",
|
"integration_type": "entity",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["infrared-protocols==5.6.0"]
|
"requirements": ["infrared-protocols==5.6.1"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ def log_rate_limits(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Output rate limit log line at given level."""
|
"""Output rate limit log line at given level."""
|
||||||
rate_limits = resp["rateLimits"]
|
rate_limits = resp["rateLimits"]
|
||||||
resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"])
|
resets_at = dt_util.parse_datetime(rate_limits["resetsAt"])
|
||||||
resetsAtTime = resetsAt - dt_util.utcnow() if resetsAt is not None else "---"
|
resets_at_time = resets_at - dt_util.utcnow() if resets_at is not None else "---"
|
||||||
rate_limit_msg = (
|
rate_limit_msg = (
|
||||||
"iOS push notification rate limits for %s: "
|
"iOS push notification rate limits for %s: "
|
||||||
"%d sent, %d allowed, %d errors, "
|
"%d sent, %d allowed, %d errors, "
|
||||||
@@ -44,7 +44,7 @@ def log_rate_limits(
|
|||||||
rate_limits["successful"],
|
rate_limits["successful"],
|
||||||
rate_limits["maximum"],
|
rate_limits["maximum"],
|
||||||
rate_limits["errors"],
|
rate_limits["errors"],
|
||||||
str(resetsAtTime).split(".", maxsplit=1)[0],
|
str(resets_at_time).split(".", maxsplit=1)[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]
|
|||||||
"Unable to connect and retrieve data from israelrail api",
|
"Unable to connect and retrieve data from israelrail api",
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
now = dt_util.now()
|
||||||
|
while offset < len(train_routes):
|
||||||
|
route = train_routes[offset]
|
||||||
|
if route is None:
|
||||||
|
break
|
||||||
|
route_departure = departure_time(route)
|
||||||
|
if route_departure is None or route_departure >= now:
|
||||||
|
break
|
||||||
|
offset += 1
|
||||||
|
|
||||||
return [
|
return [
|
||||||
DataConnection(
|
DataConnection(
|
||||||
departure=departure_time(train_routes[i]),
|
departure=departure_time(train_routes[i]),
|
||||||
@@ -89,6 +100,6 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]
|
|||||||
start=station_name_to_id(train_routes[i].trains[0].src),
|
start=station_name_to_id(train_routes[i].trains[0].src),
|
||||||
destination=station_name_to_id(train_routes[i].trains[-1].dst),
|
destination=station_name_to_id(train_routes[i].trains[-1].dst),
|
||||||
)
|
)
|
||||||
for i in range(DEPARTURES_COUNT)
|
for i in range(offset, offset + DEPARTURES_COUNT)
|
||||||
if len(train_routes) > i and train_routes[i] is not None
|
if len(train_routes) > i and train_routes[i] is not None
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -52,30 +52,46 @@ DEPARTURE_SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
|
SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
|
||||||
IsraelRailSensorEntityDescription(
|
*[
|
||||||
key="platform",
|
IsraelRailSensorEntityDescription(
|
||||||
translation_key="platform",
|
key=f"platform{i or ''}",
|
||||||
value_fn=lambda data_connection: data_connection.platform,
|
translation_key=f"platform{i or ''}",
|
||||||
),
|
value_fn=lambda data_connection: data_connection.platform,
|
||||||
IsraelRailSensorEntityDescription(
|
index=i,
|
||||||
key="trains",
|
)
|
||||||
translation_key="trains",
|
for i in range(DEPARTURES_COUNT)
|
||||||
value_fn=lambda data_connection: data_connection.trains,
|
],
|
||||||
),
|
*[
|
||||||
IsraelRailSensorEntityDescription(
|
IsraelRailSensorEntityDescription(
|
||||||
key="train_number",
|
key=f"trains{i or ''}",
|
||||||
translation_key="train_number",
|
translation_key=f"trains{i or ''}",
|
||||||
value_fn=lambda data_connection: data_connection.train_number,
|
value_fn=lambda data_connection: data_connection.trains,
|
||||||
),
|
index=i,
|
||||||
IsraelRailSensorEntityDescription(
|
)
|
||||||
key="departure_delay",
|
for i in range(DEPARTURES_COUNT)
|
||||||
translation_key="departure_delay",
|
],
|
||||||
device_class=SensorDeviceClass.DURATION,
|
*[
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
IsraelRailSensorEntityDescription(
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
key=f"train_number{i or ''}",
|
||||||
suggested_display_precision=0,
|
translation_key=f"train_number{i or ''}",
|
||||||
value_fn=lambda data_connection: data_connection.departure_delay,
|
value_fn=lambda data_connection: data_connection.train_number,
|
||||||
),
|
index=i,
|
||||||
|
)
|
||||||
|
for i in range(DEPARTURES_COUNT)
|
||||||
|
],
|
||||||
|
*[
|
||||||
|
IsraelRailSensorEntityDescription(
|
||||||
|
key=f"departure_delay{i or ''}",
|
||||||
|
translation_key=f"departure_delay{i or ''}",
|
||||||
|
device_class=SensorDeviceClass.DURATION,
|
||||||
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data_connection: data_connection.departure_delay,
|
||||||
|
index=i,
|
||||||
|
)
|
||||||
|
for i in range(DEPARTURES_COUNT)
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user