mirror of
https://github.com/home-assistant/core.git
synced 2026-05-28 03:33:19 +02:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fce17c8e6f | |||
| 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.
|
||||
- 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.
|
||||
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
+97
-204
@@ -60,9 +60,7 @@ env:
|
||||
# - 15.2 is the latest (as of 9 Feb 2023)
|
||||
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
|
||||
UV_CACHE_DIR: /tmp/uv-cache
|
||||
APT_CACHE_BASE: /home/runner/work/apt
|
||||
APT_CACHE_DIR: /home/runner/work/apt/cache
|
||||
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
|
||||
APT_CACHE_VERSION: 1
|
||||
SQLALCHEMY_WARN_20: 1
|
||||
PYTHONASYNCIODEBUG: 1
|
||||
HASS_CI: 1
|
||||
@@ -86,7 +84,6 @@ jobs:
|
||||
core: ${{ steps.core.outputs.changes }}
|
||||
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
|
||||
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 }}
|
||||
requirements: ${{ steps.core.outputs.requirements }}
|
||||
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
|
||||
@@ -116,10 +113,6 @@ jobs:
|
||||
# Include HA_SHORT_VERSION to force the immediate creation
|
||||
# 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
|
||||
- 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
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: core
|
||||
@@ -384,65 +377,36 @@ jobs:
|
||||
path: ${{ env.UV_CACHE_DIR }}
|
||||
key: ${{ steps.generate-uv-key.outputs.full_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
|
||||
if: |
|
||||
steps.cache-venv.outputs.cache-hit != 'true'
|
||||
|| steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
id: install-os-deps
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
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
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
libavcodec-dev
|
||||
libavdevice-dev
|
||||
libavfilter-dev
|
||||
libavformat-dev
|
||||
libavutil-dev
|
||||
libswresample-dev
|
||||
libswscale-dev
|
||||
libudev-dev
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Read uv version from requirements.txt
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
id: read-uv-version
|
||||
run: |
|
||||
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
|
||||
- name: Set up uv
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: ${{ steps.read-uv-version.outputs.version }}
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
id: create-venv
|
||||
@@ -450,8 +414,6 @@ jobs:
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
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_all.txt -r requirements_test.txt
|
||||
uv pip install -e . --config-settings editable_mode=compat
|
||||
@@ -506,30 +468,16 @@ jobs:
|
||||
&& github.event.inputs.mypy-only != 'true'
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
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
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
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
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -876,32 +824,20 @@ jobs:
|
||||
- info
|
||||
- base
|
||||
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
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
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
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -952,33 +888,21 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
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
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
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 }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -1105,34 +1029,22 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||
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
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
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 }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -1266,36 +1178,29 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||
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
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
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 }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -1449,33 +1354,21 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
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
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
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 }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.13
|
||||
rev: v0.15.14
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -337,6 +337,7 @@ homeassistant.components.led_ble.*
|
||||
homeassistant.components.lektrico.*
|
||||
homeassistant.components.letpot.*
|
||||
homeassistant.components.lg_infrared.*
|
||||
homeassistant.components.lg_tv_rs232.*
|
||||
homeassistant.components.libre_hardware_monitor.*
|
||||
homeassistant.components.lidarr.*
|
||||
homeassistant.components.liebherr.*
|
||||
@@ -428,6 +429,7 @@ homeassistant.components.otp.*
|
||||
homeassistant.components.ouman_eh_800.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.overseerr.*
|
||||
homeassistant.components.ovhcloud_ai_endpoints.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
homeassistant.components.paj_gps.*
|
||||
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.
|
||||
- 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.
|
||||
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
Generated
+8
-2
@@ -987,6 +987,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||
/homeassistant/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
|
||||
/tests/components/libre_hardware_monitor/ @Sab44
|
||||
/homeassistant/components/lichess/ @aryanhasgithub
|
||||
@@ -1290,6 +1292,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/openhome/ @bazwilliams
|
||||
/homeassistant/components/openrgb/ @felipecrs
|
||||
/tests/components/openrgb/ @felipecrs
|
||||
/homeassistant/components/opensensemap/ @AlCalzone
|
||||
/tests/components/opensensemap/ @AlCalzone
|
||||
/homeassistant/components/opensky/ @joostlek
|
||||
/tests/components/opensky/ @joostlek
|
||||
/homeassistant/components/opentherm_gw/ @mvn23
|
||||
@@ -1317,6 +1321,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/overkiz/ @imicknl
|
||||
/homeassistant/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
|
||||
/tests/components/ovo_energy/ @timmo001
|
||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||
@@ -2048,8 +2054,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/yamaha_musiccast/ @vigonotion @micha91
|
||||
/homeassistant/components/yandex_transport/ @rishatik92 @devbis
|
||||
/tests/components/yandex_transport/ @rishatik92 @devbis
|
||||
/homeassistant/components/yardian/ @h3l1o5
|
||||
/tests/components/yardian/ @h3l1o5
|
||||
/homeassistant/components/yardian/ @aeon-matrix
|
||||
/tests/components/yardian/ @aeon-matrix
|
||||
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
||||
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
||||
/homeassistant/components/yeelightsunflower/ @lindsaymarkward
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.components.select import (
|
||||
PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA,
|
||||
SelectEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import CONF_NAME, CONF_OPTIONS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -19,9 +19,6 @@ from .hub import AdsHub
|
||||
|
||||
DEFAULT_NAME = "ADS select"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_OPTIONS = "options"
|
||||
|
||||
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ADS_VAR): cv.string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -17,6 +17,7 @@ PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.EVENT,
|
||||
Platform.MEDIA_PLAYER,
|
||||
Platform.NOTIFY,
|
||||
Platform.SENSOR,
|
||||
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.sync_history_state()
|
||||
await coordinator.sync_media_state()
|
||||
|
||||
async def _on_http2_reauth_required() -> None:
|
||||
entry.async_start_reauth(hass)
|
||||
|
||||
@@ -8,7 +8,12 @@ from aioamazondevices.exceptions import (
|
||||
CannotConnect,
|
||||
CannotRetrieveData,
|
||||
)
|
||||
from aioamazondevices.structures import AmazonDevice, AmazonVocalRecord
|
||||
from aioamazondevices.structures import (
|
||||
AmazonDevice,
|
||||
AmazonMediaState,
|
||||
AmazonVocalRecord,
|
||||
AmazonVolumeState,
|
||||
)
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -74,10 +79,17 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
}
|
||||
|
||||
self._vocal_records: dict[str, AmazonVocalRecord] = {}
|
||||
|
||||
self.api.on_history_event.append(self.history_state_event_handler)
|
||||
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]:
|
||||
"""Update device data."""
|
||||
try:
|
||||
@@ -189,3 +201,31 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
def vocal_records(self) -> dict[str, AmazonVocalRecord]:
|
||||
"""Vocal records of devices."""
|
||||
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)
|
||||
@@ -5,12 +5,8 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.components.hassio import HassioNotReadyError
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
@@ -53,7 +49,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
|
||||
|
||||
LABS_SNAPSHOT_FEATURE = "snapshots"
|
||||
|
||||
@@ -62,39 +57,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the analytics integration."""
|
||||
analytics_config = config.get(DOMAIN, {})
|
||||
|
||||
snapshots_url: str | None = None
|
||||
if CONF_SNAPSHOTS_URL in analytics_config:
|
||||
await labs.async_update_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
|
||||
)
|
||||
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
|
||||
else:
|
||||
snapshots_url = None
|
||||
|
||||
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
|
||||
|
||||
discovery_flow.async_create_flow(
|
||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Analytics from a config entry."""
|
||||
snapshots_url = hass.data[_DATA_SNAPSHOTS_URL]
|
||||
analytics = Analytics(hass, snapshots_url)
|
||||
|
||||
try:
|
||||
await analytics.load()
|
||||
except HassioNotReadyError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_not_ready",
|
||||
) from err
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
|
||||
started = False
|
||||
|
||||
@@ -106,18 +80,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if started:
|
||||
await analytics.async_schedule()
|
||||
|
||||
async def start_schedule(hass: HomeAssistant) -> None:
|
||||
"""Start the send schedule once Home Assistant has started."""
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
nonlocal started
|
||||
started = True
|
||||
await analytics.async_schedule()
|
||||
|
||||
entry.async_on_unload(
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
)
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
)
|
||||
entry.async_on_unload(async_at_started(hass, start_schedule))
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
hass.data[DATA_COMPONENT] = analytics
|
||||
return True
|
||||
@@ -132,9 +109,7 @@ def websocket_analytics(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return analytics preferences."""
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
|
||||
@@ -155,10 +130,8 @@ async def websocket_analytics_preferences(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update analytics preferences."""
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
preferences = msg[ATTR_PREFERENCES]
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
|
||||
await analytics.save_preferences(preferences)
|
||||
await analytics.async_schedule()
|
||||
|
||||
@@ -299,8 +299,12 @@ class Analytics:
|
||||
self._data = AnalyticsData.from_dict(stored)
|
||||
|
||||
if self.supervisor and not self.onboarded:
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable.
|
||||
# The caller is responsible for handling this and triggering a retry.
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable
|
||||
# during setup of the Supervisor integration. That will fail setup
|
||||
# of this integration. However there is no better option at this time
|
||||
# since we need to get the diagnostic setting from Supervisor to correctly
|
||||
# setup this integration and we can't raise ConfigEntryNotReady to
|
||||
# trigger a retry from async_setup.
|
||||
supervisor_info = hassio.get_supervisor_info(self._hass)
|
||||
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
@@ -345,10 +349,10 @@ class Analytics:
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
# Try to pull Supervisor information, but don't fail if some or all
|
||||
# of it is unavailable due to setup failures in the hassio integration.
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
# get_supervisor_info was called during setup so we can't get here
|
||||
# if it raised. The others may raise HassioNotReadyError if only some
|
||||
# data was successfully fetched from Supervisor
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
operating_system_info = hassio.get_os_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
@@ -626,16 +630,6 @@ class Analytics:
|
||||
err,
|
||||
)
|
||||
|
||||
@callback
|
||||
def cancel_scheduled(self) -> None:
|
||||
"""Cancel all scheduled analytics tasks."""
|
||||
if self._basic_scheduled is not None:
|
||||
self._basic_scheduled()
|
||||
self._basic_scheduled = None
|
||||
if self._snapshot_scheduled is not None:
|
||||
self._snapshot_scheduled()
|
||||
self._snapshot_scheduled = None
|
||||
|
||||
async def async_schedule(self) -> None:
|
||||
"""Schedule analytics."""
|
||||
if not self.onboarded:
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Config flow for Analytics integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Analytics."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_system(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
return self.async_create_entry(title="Analytics", data={})
|
||||
@@ -3,7 +3,6 @@
|
||||
"name": "Analytics",
|
||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["api", "websocket_api", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
@@ -15,6 +14,5 @@
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"single_config_entry": true
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"supervisor_not_ready": {
|
||||
"message": "Supervisor was not ready during setup, will retry"
|
||||
}
|
||||
},
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
|
||||
@@ -230,13 +230,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 3:
|
||||
# Remove Temperature parameter
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
temperature_key = "temperature"
|
||||
|
||||
for subentry in entry.subentries.values():
|
||||
data = subentry.data.copy()
|
||||
if CONF_TEMPERATURE not in data:
|
||||
if temperature_key not in data:
|
||||
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_entry(entry, minor_version=4)
|
||||
|
||||
@@ -7,27 +7,3 @@ CONNECTION_TIMEOUT: int = 10
|
||||
|
||||
# Field name of last self test retrieved from apcupsd.
|
||||
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."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
import dateutil
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -24,11 +23,9 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
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 .entity import APCUPSdEntity
|
||||
|
||||
@@ -36,6 +33,20 @@ PARALLEL_UPDATES = 0
|
||||
|
||||
_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] = {
|
||||
"alarmdel": SensorEntityDescription(
|
||||
key="alarmdel",
|
||||
@@ -49,18 +60,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
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(
|
||||
key="badbatts",
|
||||
translation_key="bad_batteries",
|
||||
@@ -100,12 +99,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
),
|
||||
"date": SensorEntityDescription(
|
||||
key="date",
|
||||
translation_key="date",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"dipsw": SensorEntityDescription(
|
||||
key="dipsw",
|
||||
translation_key="dip_switch_settings",
|
||||
@@ -132,23 +125,11 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="wake_delay",
|
||||
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(
|
||||
key="extbatts",
|
||||
translation_key="external_batteries",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"firmware": SensorEntityDescription(
|
||||
key="firmware",
|
||||
translation_key="firmware_version",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"hitrans": SensorEntityDescription(
|
||||
key="hitrans",
|
||||
translation_key="transfer_high",
|
||||
@@ -264,12 +245,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="min_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"model": SensorEntityDescription(
|
||||
key="model",
|
||||
translation_key="model",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"nombattv": SensorEntityDescription(
|
||||
key="nombattv",
|
||||
translation_key="battery_nominal_voltage",
|
||||
@@ -358,12 +333,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"serialno": SensorEntityDescription(
|
||||
key="serialno",
|
||||
translation_key="serial_number",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"starttime": SensorEntityDescription(
|
||||
key="starttime",
|
||||
translation_key="startup_time",
|
||||
@@ -404,18 +373,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="ups_mode",
|
||||
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(
|
||||
key="xoffbat",
|
||||
translation_key="transfer_from_battery",
|
||||
@@ -481,9 +438,10 @@ async def async_setup_entry(
|
||||
# as unknown initially.
|
||||
#
|
||||
# We also sort the resources to ensure the order of entities
|
||||
# created is deterministic since "APCMODEL" and "MODEL"
|
||||
# resources map to the same "Model" name.
|
||||
# created is deterministic
|
||||
for resource in sorted(available_resources | {LAST_S_TEST}):
|
||||
if resource in IGNORED_SENSORS:
|
||||
continue
|
||||
if resource not in SENSORS:
|
||||
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
||||
continue
|
||||
@@ -561,63 +519,3 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
self._attr_native_value, inferred_unit = infer_unit(data)
|
||||
if not self.native_unit_of_measurement:
|
||||
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": {
|
||||
"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:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -49,6 +49,20 @@ SENSORS_TYPE_COUNT = "sensors_count"
|
||||
|
||||
_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:
|
||||
"""Data handler for AsusWrt sensor."""
|
||||
@@ -187,20 +201,6 @@ class AsusWrtRouter:
|
||||
|
||||
def _migrate_entities_unique_id(self) -> None:
|
||||
"""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)
|
||||
router_entries = er.async_entries_for_config_entry(
|
||||
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",
|
||||
) from err
|
||||
except ValueError as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_endpoint_url",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
|
||||
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::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": {
|
||||
"user": {
|
||||
@@ -48,6 +48,9 @@
|
||||
},
|
||||
"invalid_credentials": {
|
||||
"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:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.3.0",
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.14",
|
||||
"habluetooth==6.7.4"
|
||||
"dbus-fast==5.0.15",
|
||||
"habluetooth==6.7.9"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,8 +32,16 @@ OPTIONS_SCHEMA = KNOWN_HOSTS_SCHEMA.extend(
|
||||
vol.Required(CONF_MORE_OPTIONS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_UUID): str,
|
||||
vol.Optional(CONF_IGNORE_CEC): str,
|
||||
vol.Optional(CONF_UUID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
custom_value=True, options=[], multiple=True
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_IGNORE_CEC): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
custom_value=True, options=[], multiple=True
|
||||
),
|
||||
),
|
||||
}
|
||||
),
|
||||
SectionConfig(collapsed=True),
|
||||
@@ -109,13 +117,11 @@ class CastOptionsFlowHandler(OptionsFlow):
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the Google Cast options."""
|
||||
if user_input is not None:
|
||||
ignore_cec = _string_to_list(
|
||||
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, "")
|
||||
ignore_cec = _trim_items(
|
||||
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, [])
|
||||
)
|
||||
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
|
||||
wanted_uuid = _string_to_list(
|
||||
user_input[CONF_MORE_OPTIONS].get(CONF_UUID, "")
|
||||
)
|
||||
wanted_uuid = _trim_items(user_input[CONF_MORE_OPTIONS].get(CONF_UUID, []))
|
||||
updated_config = dict(self.config_entry.data)
|
||||
updated_config[CONF_IGNORE_CEC] = ignore_cec
|
||||
updated_config[CONF_KNOWN_HOSTS] = known_hosts
|
||||
@@ -132,9 +138,7 @@ class CastOptionsFlowHandler(OptionsFlow):
|
||||
for key in (CONF_UUID, CONF_IGNORE_CEC):
|
||||
if key not in self.config_entry.data:
|
||||
continue
|
||||
suggested[CONF_MORE_OPTIONS][key] = _list_to_string(
|
||||
self.config_entry.data[key]
|
||||
)
|
||||
suggested[CONF_MORE_OPTIONS][key] = self.config_entry.data[key]
|
||||
|
||||
return self.async_show_form(
|
||||
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]:
|
||||
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%]",
|
||||
"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"
|
||||
},
|
||||
"user": {
|
||||
@@ -24,6 +28,10 @@
|
||||
"name": "The name of the certificate",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
|
||||
EntityNumericalStateChangedTriggerBase,
|
||||
EntityNumericalStateChangedTriggerWithUnitBase,
|
||||
EntityNumericalStateCrossedThresholdTriggerBase,
|
||||
@@ -26,7 +26,7 @@ from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
|
||||
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_HVAC_MODE): vol.All(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -175,7 +175,6 @@ class ConfigManagerFlowIndexView(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("handler"): vol.Any(str, list),
|
||||
vol.Optional("show_advanced_options", default=False): cv.boolean,
|
||||
vol.Optional("entry_id"): cv.string,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -302,7 +301,6 @@ class SubentryManagerFlowIndexView(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
|
||||
vol.Optional("show_advanced_options", default=False): cv.boolean,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["data-grand-lyon-ha==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -49,13 +49,15 @@ rules:
|
||||
status: exempt
|
||||
comment: This is a service integration; there are no discoverable devices.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: 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-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
@@ -66,7 +68,9 @@ rules:
|
||||
repair-issues:
|
||||
status: exempt
|
||||
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
|
||||
async-dependency: done
|
||||
|
||||
@@ -22,6 +22,7 @@ from .const import ( # noqa: F401
|
||||
ATTR_LOCATION_NAME,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
CONF_ASSOCIATED_ZONE,
|
||||
CONF_CONSIDER_HOME,
|
||||
CONF_NEW_DEVICE_DEFAULTS,
|
||||
CONF_SCAN_INTERVAL,
|
||||
|
||||
@@ -36,6 +36,8 @@ DEFAULT_CONSIDER_HOME: Final = timedelta(seconds=180)
|
||||
|
||||
CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults"
|
||||
|
||||
CONF_ASSOCIATED_ZONE: Final = "associated_zone"
|
||||
|
||||
ATTR_ATTRIBUTES: Final = "attributes"
|
||||
ATTR_BATTERY: Final = "battery"
|
||||
ATTR_DEV_ID: Final = "dev_id"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Provide functionality to keep track of devices."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, final
|
||||
from typing import TYPE_CHECKING, Any, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
@@ -16,8 +16,19 @@ from homeassistant.const import (
|
||||
STATE_NOT_HOME,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, State, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.core import (
|
||||
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 (
|
||||
DeviceInfo,
|
||||
EventDeviceRegistryUpdatedData,
|
||||
@@ -25,6 +36,7 @@ from homeassistant.helpers.device_registry import (
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
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 .const import (
|
||||
@@ -33,6 +45,7 @@ from .const import (
|
||||
ATTR_IP,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
CONF_ASSOCIATED_ZONE,
|
||||
CONNECTED_DEVICE_REGISTERED,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
@@ -221,8 +234,8 @@ class TrackerEntity(
|
||||
"""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
|
||||
and discards zones which do not exist. Ignored if latitude and
|
||||
longitude are both set.
|
||||
and discards zones which do not exist. Takes precedence over latitude
|
||||
and longitude when set (including when set to an empty list).
|
||||
"""
|
||||
return self._attr_in_zones
|
||||
|
||||
@@ -252,11 +265,7 @@ class TrackerEntity(
|
||||
@callback
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Calculate active zones."""
|
||||
if 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
|
||||
)
|
||||
elif (zones := self.in_zones) is not None:
|
||||
if (zones := self.in_zones) is not None:
|
||||
zone_states = sorted(
|
||||
(
|
||||
zone_state
|
||||
@@ -270,6 +279,12 @@ class TrackerEntity(
|
||||
None,
|
||||
)
|
||||
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:
|
||||
self.__active_zone = None
|
||||
self.__in_zones = None
|
||||
@@ -317,14 +332,120 @@ class BaseScannerEntity(BaseTrackerEntity):
|
||||
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
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the device."""
|
||||
if self.is_connected is 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_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
|
||||
def is_connected(self) -> bool | None:
|
||||
@@ -341,9 +462,18 @@ class BaseScannerEntity(BaseTrackerEntity):
|
||||
if not self.is_connected:
|
||||
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] = [
|
||||
zone.ENTITY_ID_HOME,
|
||||
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
|
||||
associated_zone,
|
||||
*zone.async_get_enclosing_zones(self.hass, associated_zone),
|
||||
]
|
||||
|
||||
return attr
|
||||
|
||||
@@ -38,6 +38,9 @@ from homeassistant.const import (
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
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 (
|
||||
async_track_time_interval,
|
||||
async_track_utc_time_change,
|
||||
@@ -379,8 +382,8 @@ async def async_extract_config(
|
||||
if platform.type == PLATFORM_TYPE_LEGACY:
|
||||
legacy.append(platform)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unable to determine type for {platform.name}: {platform.type}"
|
||||
async_create_platform_config_not_supported_issue(
|
||||
hass, platform.name, DOMAIN
|
||||
)
|
||||
|
||||
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": {
|
||||
"see": {
|
||||
"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
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -64,23 +64,23 @@
|
||||
"ventilation_state": {
|
||||
"name": "Ventilation state",
|
||||
"state": {
|
||||
"aut1": "Automatic boost (15 min)",
|
||||
"aut2": "Automatic boost (30 min)",
|
||||
"aut3": "Automatic boost (45 min)",
|
||||
"auto": "Automatic",
|
||||
"cnt1": "Continuous low speed",
|
||||
"cnt2": "Continuous medium speed",
|
||||
"cnt3": "Continuous high speed",
|
||||
"empt": "Empty house",
|
||||
"man1": "Manual low speed (15 min)",
|
||||
"man1x2": "Manual low speed (30 min)",
|
||||
"man1x3": "Manual low speed (45 min)",
|
||||
"man2": "Manual medium speed (15 min)",
|
||||
"man2x2": "Manual medium speed (30 min)",
|
||||
"man2x3": "Manual medium speed (45 min)",
|
||||
"man3": "Manual high speed (15 min)",
|
||||
"man3x2": "Manual high speed (30 min)",
|
||||
"man3x3": "Manual high speed (45 min)"
|
||||
"aut1": "AUT1",
|
||||
"aut2": "AUT2",
|
||||
"aut3": "AUT3",
|
||||
"auto": "AUTO",
|
||||
"cnt1": "CNT1",
|
||||
"cnt2": "CNT2",
|
||||
"cnt3": "CNT3",
|
||||
"empt": "EMPT",
|
||||
"man1": "MAN1",
|
||||
"man1x2": "MAN1x2",
|
||||
"man1x3": "MAN1x3",
|
||||
"man2": "MAN2",
|
||||
"man2x2": "MAN2x2",
|
||||
"man2x3": "MAN2x3",
|
||||
"man3": "MAN3",
|
||||
"man3x2": "MAN3x2",
|
||||
"man3x3": "MAN3x3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self.stations = {}
|
||||
for station in stations:
|
||||
label = station["label"]
|
||||
rloId = station["RLOIid"]
|
||||
rlo_id = station["RLOIid"]
|
||||
|
||||
# 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']
|
||||
@@ -50,11 +50,11 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# Similar for RLOIid
|
||||
# E.g. 0018 has an RLOIid of ['10427', '9154']
|
||||
if isinstance(rloId, list):
|
||||
rloId = rloId[-1]
|
||||
if isinstance(rlo_id, list):
|
||||
rlo_id = rlo_id[-1]
|
||||
|
||||
fullName = label + " - " + rloId
|
||||
self.stations[fullName] = station["stationReference"]
|
||||
full_name = label + " - " + rlo_id
|
||||
self.stations[full_name] = station["stationReference"]
|
||||
|
||||
if not self.stations:
|
||||
return self.async_abort(reason="no_stations")
|
||||
|
||||
@@ -40,6 +40,7 @@ ELK_ELEMENTS = {
|
||||
EVENT_ELKM1_KEYPAD_KEY_PRESSED = "elkm1.keypad_key_pressed"
|
||||
|
||||
|
||||
ATTR_DURATION = "duration"
|
||||
ATTR_KEYPAD_ID = "keypad_id"
|
||||
ATTR_KEY = "key"
|
||||
ATTR_KEY_NAME = "key_name"
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
},
|
||||
"speak_word": {
|
||||
"service": "mdi:message-minus"
|
||||
},
|
||||
"switch_output_turn_on_for": {
|
||||
"service": "mdi:timer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,3 +161,15 @@ sensor_zone_trigger:
|
||||
entity:
|
||||
integration: elkm1
|
||||
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"
|
||||
},
|
||||
"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)."""
|
||||
|
||||
from datetime import timedelta
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
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.outputs import Output
|
||||
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.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
from . import ElkM1ConfigEntry
|
||||
from .const import ATTR_DURATION, DOMAIN
|
||||
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
|
||||
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(
|
||||
hass: HomeAssistant,
|
||||
@@ -32,6 +48,15 @@ async def async_setup_entry(
|
||||
)
|
||||
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):
|
||||
"""Elk output as switch."""
|
||||
@@ -51,6 +76,10 @@ class ElkOutput(ElkAttachedEntity, SwitchEntity):
|
||||
"""Turn off the output."""
|
||||
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):
|
||||
"""Elk Thermostat emergency heat as switch."""
|
||||
@@ -79,3 +108,7 @@ class ElkThermostatEMHeat(ElkEntity, SwitchEntity):
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the output."""
|
||||
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/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==45.2.2",
|
||||
"aioesphomeapi==45.3.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.9.1"
|
||||
],
|
||||
|
||||
@@ -124,11 +124,11 @@ async def async_setup_entry(
|
||||
|
||||
for camera in coordinator.data:
|
||||
device_category = coordinator.data[camera].get("device_category")
|
||||
supportExt = coordinator.data[camera].get("supportExt")
|
||||
support_ext = coordinator.data[camera].get("supportExt")
|
||||
if (
|
||||
device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value
|
||||
and supportExt
|
||||
and str(SupportExt.SupportBatteryManage.value) in supportExt
|
||||
and support_ext
|
||||
and str(SupportExt.SupportBatteryManage.value) in support_ext
|
||||
):
|
||||
entities.append(
|
||||
EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"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
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -7,19 +7,30 @@ from google_air_quality_api.auth import Auth
|
||||
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
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.typing import ConfigType
|
||||
|
||||
from .const import CONF_REFERRER
|
||||
from .const import CONF_REFERRER, DOMAIN
|
||||
from .coordinator import (
|
||||
GoogleAirQualityConfigEntry,
|
||||
GoogleAirQualityRuntimeData,
|
||||
GoogleAirQualityUpdateCoordinator,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
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(
|
||||
hass: HomeAssistant, entry: GoogleAirQualityConfigEntry
|
||||
|
||||
@@ -11,5 +11,10 @@
|
||||
"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": {
|
||||
"device_not_found": {
|
||||
"message": "Location not found."
|
||||
},
|
||||
"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."""
|
||||
res = await self._api.get_user(params={"fields": "storageQuota"})
|
||||
|
||||
storageQuota = res["storageQuota"]
|
||||
limit = storageQuota.get("limit")
|
||||
storage_quota = res["storageQuota"]
|
||||
limit = storage_quota.get("limit")
|
||||
return StorageQuotaData(
|
||||
limit=int(limit) if limit is not None else None,
|
||||
usage=int(storageQuota.get("usage", 0)),
|
||||
usage_in_drive=int(storageQuota.get("usageInDrive", 0)),
|
||||
usage_in_trash=int(storageQuota.get("usageInTrash", 0)),
|
||||
usage=int(storage_quota.get("usage", 0)),
|
||||
usage_in_drive=int(storage_quota.get("usageInDrive", 0)),
|
||||
usage_in_trash=int(storage_quota.get("usageInTrash", 0)),
|
||||
)
|
||||
|
||||
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_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_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
|
||||
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
|
||||
|
||||
@@ -580,17 +580,17 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
|
||||
if tool_results:
|
||||
messages.append(_create_google_tool_response_content(tool_results))
|
||||
generateContentConfig = self.create_generate_content_config()
|
||||
generateContentConfig.tools = tools or None
|
||||
generateContentConfig.system_instruction = (
|
||||
generate_content_config = self.create_generate_content_config()
|
||||
generate_content_config.tools = tools or None
|
||||
generate_content_config.system_instruction = (
|
||||
prompt if supports_system_instruction else None
|
||||
)
|
||||
generateContentConfig.automatic_function_calling = (
|
||||
generate_content_config.automatic_function_calling = (
|
||||
AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None)
|
||||
)
|
||||
if structure:
|
||||
generateContentConfig.response_mime_type = "application/json"
|
||||
generateContentConfig.response_schema = _format_schema(
|
||||
generate_content_config.response_mime_type = "application/json"
|
||||
generate_content_config.response_schema = _format_schema(
|
||||
convert(
|
||||
structure,
|
||||
custom_serializer=(
|
||||
@@ -608,7 +608,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
*messages,
|
||||
]
|
||||
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]
|
||||
assert isinstance(user_message, conversation.UserContent)
|
||||
|
||||
@@ -313,7 +313,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse:
|
||||
)
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
FUNC_MAP = {
|
||||
func_map = {
|
||||
SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest,
|
||||
SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_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,
|
||||
}
|
||||
|
||||
func = FUNC_MAP[call.service]
|
||||
func = func_map[call.service]
|
||||
|
||||
try:
|
||||
response = await func()
|
||||
|
||||
@@ -131,12 +131,8 @@ ATTR_AUTO_UPDATE = "auto_update"
|
||||
ATTR_VERSION = "version"
|
||||
ATTR_VERSION_LATEST = "version_latest"
|
||||
ATTR_CPU_PERCENT = "cpu_percent"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_LOCATION = "location"
|
||||
ATTR_MEMORY_PERCENT = "memory_percent"
|
||||
ATTR_SLUG = "slug"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_STATE = "state"
|
||||
ATTR_STARTED = "started"
|
||||
ATTR_URL = "url"
|
||||
ATTR_REPOSITORY = "repository"
|
||||
@@ -177,19 +173,6 @@ CORE_CONTAINER = "homeassistant"
|
||||
SUPERVISOR_CONTAINER = "hassio_supervisor"
|
||||
|
||||
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
|
||||
|
||||
HELP_URLS = {
|
||||
|
||||
@@ -15,7 +15,7 @@ from aiohasupervisor.models import (
|
||||
)
|
||||
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 (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
@@ -43,7 +43,6 @@ from .const import (
|
||||
ATTR_HOMEASSISTANT,
|
||||
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
|
||||
ATTR_INPUT,
|
||||
ATTR_LOCATION,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_SLUG,
|
||||
DOMAIN,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Home Assistant Hardware",
|
||||
"after_dependencies": ["hassio"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"dependencies": ["usb"],
|
||||
"dependencies": ["repairs", "usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||
"integration_type": "system",
|
||||
"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
|
||||
from typing import Any, Protocol
|
||||
|
||||
from aiohttp import ClientError
|
||||
from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing
|
||||
import voluptuous as vol
|
||||
import yarl
|
||||
|
||||
@@ -25,6 +27,7 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.integration_platform import (
|
||||
async_process_integration_platforms,
|
||||
@@ -37,15 +40,18 @@ from homeassistant.helpers.selector import (
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
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__)
|
||||
|
||||
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_DEVICE = "device"
|
||||
@@ -71,53 +77,6 @@ async def get_multiprotocol_addon_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):
|
||||
"""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
|
||||
class SerialPortSettings:
|
||||
"""Serial port settings."""
|
||||
@@ -339,6 +286,19 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
||||
def _zha_name(self) -> str:
|
||||
"""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
|
||||
def flow_manager(self) -> OptionsFlowManager:
|
||||
"""Return the correct flow manager."""
|
||||
@@ -686,61 +646,7 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
||||
async def async_step_firmware_revert(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Install the flasher addon, if necessary."""
|
||||
|
||||
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."""
|
||||
"""Initiate ZHA backup and start multiprotocol addon uninstall."""
|
||||
# pylint: disable=home-assistant-component-root-import
|
||||
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # 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")
|
||||
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()
|
||||
|
||||
async def async_step_uninstall_multiprotocol_addon(
|
||||
@@ -821,62 +716,93 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
||||
finally:
|
||||
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
|
||||
) -> ConfigFlowResult:
|
||||
"""Start Silicon Labs Flasher add-on."""
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
"""Flash Zigbee firmware directly onto the radio."""
|
||||
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:
|
||||
await flasher_manager.async_start_addon_waiting()
|
||||
# Now that the addon is running, wait for it to finish
|
||||
await flasher_manager.async_wait_until_addon_state(
|
||||
AddonState.NOT_RUNNING
|
||||
)
|
||||
# For the duration of firmware flashing, hint to other integrations
|
||||
# (i.e. ZHA) that the hardware is in use and should not be accessed.
|
||||
async with async_firmware_flashing_context(self.hass, device, DOMAIN):
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = FirmwareUpdateClient(self._firmware_update_url(), session)
|
||||
|
||||
self.start_task = self.hass.async_create_task(
|
||||
start_and_wait_until_done(), eager_start=False
|
||||
try:
|
||||
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(
|
||||
step_id="start_flasher_addon",
|
||||
progress_action="start_flasher_addon",
|
||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
||||
progress_task=self.start_task,
|
||||
step_id="install_zigbee_firmware",
|
||||
progress_action="install_zigbee_firmware",
|
||||
description_placeholders={
|
||||
"hardware_name": self._hardware_name(),
|
||||
},
|
||||
progress_task=self.install_task,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.start_task
|
||||
except (AddonError, AbortFlow) as err:
|
||||
_LOGGER.error(err)
|
||||
return self.async_show_progress_done(next_step_id="flasher_failed")
|
||||
await self.install_task
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error("Failed to flash Zigbee firmware: %s", err)
|
||||
return self.async_show_progress_done(next_step_id="firmware_flash_failed")
|
||||
finally:
|
||||
self.start_task = None
|
||||
self.install_task = None
|
||||
|
||||
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
|
||||
) -> ConfigFlowResult:
|
||||
"""Flasher add-on start failed."""
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
"""Firmware flashing failed."""
|
||||
return self.async_abort(
|
||||
reason="addon_start_failed",
|
||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
||||
reason="fw_install_failed",
|
||||
description_placeholders={"firmware_name": "Zigbee"},
|
||||
)
|
||||
|
||||
async def async_step_flashing_complete(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""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
|
||||
if self._zha_migration_mgr:
|
||||
try:
|
||||
|
||||
@@ -102,7 +102,9 @@
|
||||
},
|
||||
"progress": {
|
||||
"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": {
|
||||
"addon_installed_other_device": {
|
||||
|
||||
@@ -37,13 +37,59 @@ from .const import (
|
||||
ZIGBEE_FLASHER_ADDON_SLUG,
|
||||
)
|
||||
from .helpers import async_firmware_update_context
|
||||
from .silabs_multiprotocol_addon import (
|
||||
WaitingAddonManager,
|
||||
get_multiprotocol_addon_manager,
|
||||
)
|
||||
|
||||
_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):
|
||||
"""Application type running on a device."""
|
||||
@@ -279,6 +325,11 @@ async def guess_hardware_owners(
|
||||
assert otbr_addon_fw_info is not None
|
||||
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)
|
||||
|
||||
try:
|
||||
|
||||
@@ -7,6 +7,13 @@ import os.path
|
||||
from homeassistant.components.homeassistant_hardware.coordinator import (
|
||||
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.usb import (
|
||||
USBDevice,
|
||||
@@ -92,6 +99,16 @@ async def async_setup_entry(
|
||||
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
|
||||
session = async_get_clientsession(hass)
|
||||
coordinator = FirmwareUpdateCoordinator(
|
||||
|
||||
@@ -248,6 +248,19 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
|
||||
"""Return the name of the hardware."""
|
||||
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(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> 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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"abort": {
|
||||
"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_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_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_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": {
|
||||
"addon_installed_other_device": {
|
||||
|
||||
@@ -7,8 +7,13 @@ from homeassistant.components.hassio import HassioNotReadyError, get_os_info
|
||||
from homeassistant.components.homeassistant_hardware.coordinator import (
|
||||
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 (
|
||||
check_multi_pan_addon,
|
||||
multi_pan_addon_using_device,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
@@ -27,6 +32,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FIRMWARE,
|
||||
FIRMWARE_VERSION,
|
||||
MANUFACTURER,
|
||||
@@ -77,6 +83,16 @@ async def async_setup_entry(
|
||||
except HomeAssistantError as 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:
|
||||
discovery_flow.async_create_flow(
|
||||
hass,
|
||||
|
||||
@@ -319,6 +319,19 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler(
|
||||
"""Return the name of the hardware."""
|
||||
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(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> 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": {
|
||||
"abort": {
|
||||
"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_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_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_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": {
|
||||
"addon_installed_other_device": {
|
||||
|
||||
@@ -30,6 +30,11 @@ OPEN_CLOSE_ATTRIBUTES = [
|
||||
AttributeType.UP_DOWN,
|
||||
]
|
||||
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:
|
||||
@@ -69,12 +74,6 @@ def get_cover_features(
|
||||
|
||||
def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
|
||||
"""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)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.entity import get_supported_features
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
|
||||
EntityTargetStateTriggerBase,
|
||||
Trigger,
|
||||
TriggerConfig,
|
||||
@@ -18,7 +18,7 @@ from homeassistant.helpers.trigger import (
|
||||
|
||||
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_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -15,7 +15,6 @@ CONF_INFO = "info"
|
||||
CONF_INVERTING = "inverting"
|
||||
CONF_LIGHT = "light"
|
||||
CONF_NODE = "node"
|
||||
CONF_NOTE = "note"
|
||||
CONF_OFF_ID = "off_id"
|
||||
CONF_ON_ID = "on_id"
|
||||
CONF_POSITION = "position"
|
||||
|
||||
@@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
|
||||
from homeassistant.const import (
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_NOTE,
|
||||
CONF_PASSWORD,
|
||||
CONF_TYPE,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
@@ -25,7 +26,6 @@ from .const import (
|
||||
CONF_INFO,
|
||||
CONF_INVERTING,
|
||||
CONF_LIGHT,
|
||||
CONF_NOTE,
|
||||
CONF_OFF_ID,
|
||||
CONF_ON_ID,
|
||||
CONF_POSITION,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import Heater as InComfortHeater
|
||||
|
||||
@@ -97,11 +97,13 @@ class IncomfortBinarySensor(IncomfortBoilerEntity, BinarySensorEntity):
|
||||
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
|
||||
|
||||
@property
|
||||
@override
|
||||
def is_on(self) -> bool:
|
||||
"""Return the status of the sensor."""
|
||||
return bool(self._heater.status[self.entity_description.value_key])
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the device state attributes."""
|
||||
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."""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom
|
||||
|
||||
@@ -76,16 +76,19 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
return {"status": self._room.status}
|
||||
|
||||
@property
|
||||
@override
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._room.room_temp
|
||||
|
||||
@property
|
||||
@override
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the actual current HVAC action."""
|
||||
if self._heater.is_burning and self._heater.is_pumping:
|
||||
@@ -93,6 +96,7 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
|
||||
return HVACAction.IDLE
|
||||
|
||||
@property
|
||||
@override
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the (override)temperature we try to reach.
|
||||
|
||||
@@ -106,11 +110,13 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
|
||||
return self._room.setpoint
|
||||
return self._room.override or self._room.setpoint
|
||||
|
||||
@override
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set a new target temperature for this zone."""
|
||||
temperature: float = kwargs[ATTR_TEMPERATURE]
|
||||
await self._room.set_override(temperature)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@override
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import InvalidGateway, InvalidHeaterList
|
||||
import voluptuous as vol
|
||||
@@ -100,6 +100,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
_discovered_host: str
|
||||
|
||||
@override
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
@@ -108,6 +109,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return InComfortOptionsFlowHandler()
|
||||
|
||||
@override
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
@@ -169,6 +171,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders={CONF_HOST: self._discovered_host},
|
||||
)
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from incomfortclient import (
|
||||
@@ -74,6 +74,7 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
|
||||
)
|
||||
self.incomfort_data = incomfort_data
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> InComfortData:
|
||||
"""Fetch data from API endpoint."""
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import Heater as InComfortHeater
|
||||
|
||||
@@ -104,11 +104,13 @@ class IncomfortSensor(IncomfortBoilerEntity, SensorEntity):
|
||||
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
|
||||
|
||||
@property
|
||||
@override
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return]
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the device state attributes."""
|
||||
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."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import Heater as InComfortHeater
|
||||
|
||||
@@ -49,11 +49,13 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
|
||||
self._attr_unique_id = heater.serial_no
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
return {k: v for k, v in self._heater.status.items() if k in HEATER_ATTRS}
|
||||
|
||||
@property
|
||||
@override
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self._heater.is_tapping:
|
||||
@@ -67,6 +69,7 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
|
||||
return max(self._heater.heater_temp, self._heater.tap_temp)
|
||||
|
||||
@property
|
||||
@override
|
||||
def current_operation(self) -> str | None:
|
||||
"""Return the current operation mode."""
|
||||
return self._heater.display_text
|
||||
|
||||
@@ -32,6 +32,7 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
||||
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
|
||||
IndevoltSystem.BYPASS_POWER,
|
||||
IndevoltSystem.BYPASS_INPUT_ENERGY,
|
||||
IndevoltBattery.RATED_CAPACITY,
|
||||
IndevoltBattery.DAILY_CHARGING_ENERGY,
|
||||
IndevoltBattery.DAILY_DISCHARGING_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_3,
|
||||
IndevoltSolar.DC_INPUT_POWER_4,
|
||||
IndevoltBattery.RATED_CAPACITY_GEN2,
|
||||
IndevoltBattery.RATED_CAPACITY,
|
||||
IndevoltSystem.BYPASS_POWER,
|
||||
IndevoltSystem.TOTAL_OUTPUT_ENERGY,
|
||||
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
|
||||
@@ -134,6 +135,12 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
||||
IndevoltBattery.PACK_3_CURRENT,
|
||||
IndevoltBattery.PACK_4_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_GRID_CHARGING,
|
||||
IndevoltConfig.READ_LIGHT,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Home Assistant integration for Indevolt device."""
|
||||
|
||||
from datetime import timedelta
|
||||
import itertools
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
|
||||
@@ -29,6 +30,7 @@ from .const import (
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
SCAN_BATCH_SIZE: Final = 50
|
||||
SCAN_INTERVAL: Final = 30
|
||||
|
||||
type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator]
|
||||
@@ -86,10 +88,13 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch raw JSON data from the device."""
|
||||
data: dict[str, Any] = {}
|
||||
sensor_keys = SENSOR_KEYS[self.generation]
|
||||
|
||||
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:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -97,6 +102,9 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
else:
|
||||
return data
|
||||
|
||||
async def async_push_data(self, sensor_key: str, value: Any) -> bool:
|
||||
"""Push/write data values to given key on the device."""
|
||||
return await self.api.set_data(sensor_key, value)
|
||||
|
||||
@@ -73,12 +73,10 @@ SENSORS: Final = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.RATED_CAPACITY_GEN2,
|
||||
generation=(2,),
|
||||
key=IndevoltBattery.RATED_CAPACITY,
|
||||
translation_key="rated_capacity",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltConfig.READ_DISCHARGE_LIMIT,
|
||||
@@ -132,7 +130,7 @@ SENSORS: Final = (
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.GEN_2_CYCLE_COUNT,
|
||||
generation=(2,),
|
||||
translation_key="cycle_count",
|
||||
translation_key="equivalent_full_cycles",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -794,9 +792,58 @@ SENSORS: Final = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
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 = [
|
||||
(
|
||||
IndevoltBattery.PACK_1_SERIAL_NUMBER,
|
||||
@@ -805,6 +852,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
||||
IndevoltBattery.PACK_1_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_1_VOLTAGE,
|
||||
IndevoltBattery.PACK_1_CURRENT,
|
||||
IndevoltBattery.PACK_1_CYCLES,
|
||||
),
|
||||
(
|
||||
IndevoltBattery.PACK_2_SERIAL_NUMBER,
|
||||
@@ -813,6 +861,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
||||
IndevoltBattery.PACK_2_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_2_VOLTAGE,
|
||||
IndevoltBattery.PACK_2_CURRENT,
|
||||
IndevoltBattery.PACK_2_CYCLES,
|
||||
),
|
||||
(
|
||||
IndevoltBattery.PACK_3_SERIAL_NUMBER,
|
||||
@@ -821,6 +870,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
||||
IndevoltBattery.PACK_3_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_3_VOLTAGE,
|
||||
IndevoltBattery.PACK_3_CURRENT,
|
||||
IndevoltBattery.PACK_3_CYCLES,
|
||||
),
|
||||
(
|
||||
IndevoltBattery.PACK_4_SERIAL_NUMBER,
|
||||
@@ -829,6 +879,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
||||
IndevoltBattery.PACK_4_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_4_VOLTAGE,
|
||||
IndevoltBattery.PACK_4_CURRENT,
|
||||
IndevoltBattery.PACK_4_CYCLES,
|
||||
),
|
||||
(
|
||||
IndevoltBattery.PACK_5_SERIAL_NUMBER,
|
||||
@@ -837,6 +888,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
||||
IndevoltBattery.PACK_5_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_5_VOLTAGE,
|
||||
IndevoltBattery.PACK_5_CURRENT,
|
||||
IndevoltBattery.PACK_5_CYCLES,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -118,6 +118,9 @@
|
||||
"battery_pack_1_current": {
|
||||
"name": "Battery pack 1 current"
|
||||
},
|
||||
"battery_pack_1_cycles": {
|
||||
"name": "Battery pack 1 cycle count"
|
||||
},
|
||||
"battery_pack_1_mos_temperature": {
|
||||
"name": "Battery pack 1 MOS temperature"
|
||||
},
|
||||
@@ -136,6 +139,9 @@
|
||||
"battery_pack_2_current": {
|
||||
"name": "Battery pack 2 current"
|
||||
},
|
||||
"battery_pack_2_cycles": {
|
||||
"name": "Battery pack 2 cycle count"
|
||||
},
|
||||
"battery_pack_2_mos_temperature": {
|
||||
"name": "Battery pack 2 MOS temperature"
|
||||
},
|
||||
@@ -154,6 +160,9 @@
|
||||
"battery_pack_3_current": {
|
||||
"name": "Battery pack 3 current"
|
||||
},
|
||||
"battery_pack_3_cycles": {
|
||||
"name": "Battery pack 3 cycle count"
|
||||
},
|
||||
"battery_pack_3_mos_temperature": {
|
||||
"name": "Battery pack 3 MOS temperature"
|
||||
},
|
||||
@@ -172,6 +181,9 @@
|
||||
"battery_pack_4_current": {
|
||||
"name": "Battery pack 4 current"
|
||||
},
|
||||
"battery_pack_4_cycles": {
|
||||
"name": "Battery pack 4 cycle count"
|
||||
},
|
||||
"battery_pack_4_mos_temperature": {
|
||||
"name": "Battery pack 4 MOS temperature"
|
||||
},
|
||||
@@ -190,6 +202,9 @@
|
||||
"battery_pack_5_current": {
|
||||
"name": "Battery pack 5 current"
|
||||
},
|
||||
"battery_pack_5_cycles": {
|
||||
"name": "Battery pack 5 cycle count"
|
||||
},
|
||||
"battery_pack_5_mos_temperature": {
|
||||
"name": "Battery pack 5 MOS temperature"
|
||||
},
|
||||
@@ -226,9 +241,6 @@
|
||||
"cumulative_production": {
|
||||
"name": "Cumulative production"
|
||||
},
|
||||
"cycle_count": {
|
||||
"name": "Cycle count"
|
||||
},
|
||||
"daily_production": {
|
||||
"name": "Daily production"
|
||||
},
|
||||
@@ -283,6 +295,9 @@
|
||||
"self_consumed_prioritized": "Self-consumed prioritized"
|
||||
}
|
||||
},
|
||||
"equivalent_full_cycles": {
|
||||
"name": "Equivalent full cycles"
|
||||
},
|
||||
"grid_frequency": {
|
||||
"name": "Grid frequency"
|
||||
},
|
||||
@@ -295,6 +310,9 @@
|
||||
"main_current": {
|
||||
"name": "Main current"
|
||||
},
|
||||
"main_cycles": {
|
||||
"name": "Main cycle count"
|
||||
},
|
||||
"main_mos_temperature": {
|
||||
"name": "Main MOS temperature"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user