mirror of
https://github.com/home-assistant/core.git
synced 2026-05-27 19:25:18 +02:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e4f0e143d | |||
| 218c3ffd11 | |||
| 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 | |||
| 2f3f91ec82 |
@@ -24,6 +24,7 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
from .validation import UnsupportedBoardError, async_get_supported_board_info
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,6 +44,11 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
box_name, _ = await self._validate_input(discovery_info.ip)
|
||||
except UnsupportedBoardError:
|
||||
_LOGGER.debug(
|
||||
"Unsupported Duco board discovered via DHCP at %s", discovery_info.ip
|
||||
)
|
||||
return self.async_abort(reason="unsupported_board")
|
||||
except DucoConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except DucoError:
|
||||
@@ -61,6 +67,12 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle zeroconf discovery."""
|
||||
try:
|
||||
box_name, mac = await self._validate_input(discovery_info.host)
|
||||
except UnsupportedBoardError:
|
||||
_LOGGER.debug(
|
||||
"Unsupported Duco board discovered via zeroconf at %s",
|
||||
discovery_info.host,
|
||||
)
|
||||
return self.async_abort(reason="unsupported_board")
|
||||
except DucoConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except DucoError:
|
||||
@@ -102,6 +114,8 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
try:
|
||||
box_name, mac = await self._validate_input(user_input[CONF_HOST])
|
||||
except UnsupportedBoardError:
|
||||
errors["base"] = "unsupported_board"
|
||||
except DucoConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except DucoError:
|
||||
@@ -133,6 +147,8 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
try:
|
||||
box_name, mac = await self._validate_input(user_input[CONF_HOST])
|
||||
except UnsupportedBoardError:
|
||||
errors["base"] = "unsupported_board"
|
||||
except DucoConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except DucoError:
|
||||
@@ -162,6 +178,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
session=async_get_clientsession(self.hass),
|
||||
host=host,
|
||||
)
|
||||
board_info = await client.async_get_board_info()
|
||||
board_info = await async_get_supported_board_info(client)
|
||||
lan_info = await client.async_get_lan_info()
|
||||
return board_info.box_name, lan_info.mac
|
||||
|
||||
@@ -4,7 +4,11 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from duco_connectivity import DucoClient
|
||||
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||
from duco_connectivity.exceptions import (
|
||||
DucoConnectionError,
|
||||
DucoError,
|
||||
DucoResponseError,
|
||||
)
|
||||
from duco_connectivity.models import BoardInfo, Node
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -13,6 +17,7 @@ from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
from .validation import UnsupportedBoardError, async_get_supported_board_info
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,7 +57,18 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
async def _async_setup(self) -> None:
|
||||
"""Fetch board info once during initial setup."""
|
||||
try:
|
||||
self.board_info = await self.client.async_get_board_info()
|
||||
self.board_info = await async_get_supported_board_info(self.client)
|
||||
except UnsupportedBoardError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_board",
|
||||
) from err
|
||||
except DucoResponseError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -70,20 +86,6 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
"""Fetch node data from the Duco box."""
|
||||
try:
|
||||
nodes = await self.client.async_get_nodes()
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except DucoError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
try:
|
||||
lan_info = await self.client.async_get_lan_info()
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device you entered belongs to a different Duco box.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unsupported_board": "This Duco system is not supported by this integration. The integration requires a Duco Connectivity Board running public API 2.1 or newer."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unsupported_board": "[%key:component::duco::config::abort::unsupported_board%]"
|
||||
},
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
@@ -62,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +100,9 @@
|
||||
},
|
||||
"rate_limit_exceeded": {
|
||||
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
|
||||
},
|
||||
"unsupported_board": {
|
||||
"message": "[%key:component::duco::config::abort::unsupported_board%]"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Validation helpers for supported Duco systems."""
|
||||
|
||||
from awesomeversion import (
|
||||
AwesomeVersion,
|
||||
AwesomeVersionStrategy,
|
||||
AwesomeVersionStrategyException,
|
||||
)
|
||||
from duco_connectivity import DucoClient
|
||||
from duco_connectivity.exceptions import DucoResponseError
|
||||
from duco_connectivity.models import BoardInfo
|
||||
|
||||
# Newer Connectivity boards expose /info with PublicApiVersion. We use that
|
||||
# endpoint to distinguish supported Connectivity hardware from older
|
||||
# Communication board V1 hardware.
|
||||
_MIN_PUBLIC_API_VERSION = AwesomeVersion(
|
||||
"2.1", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
|
||||
|
||||
class UnsupportedBoardError(Exception):
|
||||
"""Raised when the Duco system is not supported by this integration."""
|
||||
|
||||
|
||||
def validate_board_support(board_info: BoardInfo) -> None:
|
||||
"""Raise UnsupportedBoardError if the board does not meet support requirements."""
|
||||
version = board_info.public_api_version
|
||||
if version is None:
|
||||
raise UnsupportedBoardError("Board did not report a public API version")
|
||||
try:
|
||||
parsed_version = AwesomeVersion(
|
||||
version, ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
except AwesomeVersionStrategyException as err:
|
||||
raise UnsupportedBoardError(
|
||||
f"Board reported malformed public API version: {version}"
|
||||
) from err
|
||||
if parsed_version < _MIN_PUBLIC_API_VERSION:
|
||||
raise UnsupportedBoardError(
|
||||
"Board public API version "
|
||||
f"{version} is below the supported minimum {_MIN_PUBLIC_API_VERSION}"
|
||||
)
|
||||
|
||||
|
||||
async def async_get_supported_board_info(client: DucoClient) -> BoardInfo:
|
||||
"""Fetch and validate board info for a supported Duco system."""
|
||||
try:
|
||||
board_info = await client.async_get_board_info()
|
||||
except DucoResponseError as err:
|
||||
if err.status == 404:
|
||||
# Duco indicated that Communication board V1 does not implement
|
||||
# /info, so a 404 is enough to treat the device as unsupported.
|
||||
raise UnsupportedBoardError(
|
||||
"Board does not expose the /info endpoint"
|
||||
) from err
|
||||
raise
|
||||
|
||||
validate_board_support(board_info)
|
||||
return board_info
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["infrared-protocols==5.6.0"]
|
||||
"requirements": ["infrared-protocols==5.6.1"]
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ def log_rate_limits(
|
||||
) -> None:
|
||||
"""Output rate limit log line at given level."""
|
||||
rate_limits = resp["rateLimits"]
|
||||
resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"])
|
||||
resetsAtTime = resetsAt - dt_util.utcnow() if resetsAt is not None else "---"
|
||||
resets_at = dt_util.parse_datetime(rate_limits["resetsAt"])
|
||||
resets_at_time = resets_at - dt_util.utcnow() if resets_at is not None else "---"
|
||||
rate_limit_msg = (
|
||||
"iOS push notification rate limits for %s: "
|
||||
"%d sent, %d allowed, %d errors, "
|
||||
@@ -44,7 +44,7 @@ def log_rate_limits(
|
||||
rate_limits["successful"],
|
||||
rate_limits["maximum"],
|
||||
rate_limits["errors"],
|
||||
str(resetsAtTime).split(".", maxsplit=1)[0],
|
||||
str(resets_at_time).split(".", maxsplit=1)[0],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -79,6 +79,17 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]
|
||||
"Unable to connect and retrieve data from israelrail api",
|
||||
) from e
|
||||
|
||||
offset = 0
|
||||
now = dt_util.now()
|
||||
while offset < len(train_routes):
|
||||
route = train_routes[offset]
|
||||
if route is None:
|
||||
break
|
||||
route_departure = departure_time(route)
|
||||
if route_departure is None or route_departure >= now:
|
||||
break
|
||||
offset += 1
|
||||
|
||||
return [
|
||||
DataConnection(
|
||||
departure=departure_time(train_routes[i]),
|
||||
@@ -89,6 +100,6 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]
|
||||
start=station_name_to_id(train_routes[i].trains[0].src),
|
||||
destination=station_name_to_id(train_routes[i].trains[-1].dst),
|
||||
)
|
||||
for i in range(DEPARTURES_COUNT)
|
||||
for i in range(offset, offset + DEPARTURES_COUNT)
|
||||
if len(train_routes) > i and train_routes[i] is not None
|
||||
]
|
||||
|
||||
@@ -52,30 +52,46 @@ DEPARTURE_SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
|
||||
IsraelRailSensorEntityDescription(
|
||||
key="platform",
|
||||
translation_key="platform",
|
||||
value_fn=lambda data_connection: data_connection.platform,
|
||||
),
|
||||
IsraelRailSensorEntityDescription(
|
||||
key="trains",
|
||||
translation_key="trains",
|
||||
value_fn=lambda data_connection: data_connection.trains,
|
||||
),
|
||||
IsraelRailSensorEntityDescription(
|
||||
key="train_number",
|
||||
translation_key="train_number",
|
||||
value_fn=lambda data_connection: data_connection.train_number,
|
||||
),
|
||||
IsraelRailSensorEntityDescription(
|
||||
key="departure_delay",
|
||||
translation_key="departure_delay",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda data_connection: data_connection.departure_delay,
|
||||
),
|
||||
*[
|
||||
IsraelRailSensorEntityDescription(
|
||||
key=f"platform{i or ''}",
|
||||
translation_key=f"platform{i or ''}",
|
||||
value_fn=lambda data_connection: data_connection.platform,
|
||||
index=i,
|
||||
)
|
||||
for i in range(DEPARTURES_COUNT)
|
||||
],
|
||||
*[
|
||||
IsraelRailSensorEntityDescription(
|
||||
key=f"trains{i or ''}",
|
||||
translation_key=f"trains{i or ''}",
|
||||
value_fn=lambda data_connection: data_connection.trains,
|
||||
index=i,
|
||||
)
|
||||
for i in range(DEPARTURES_COUNT)
|
||||
],
|
||||
*[
|
||||
IsraelRailSensorEntityDescription(
|
||||
key=f"train_number{i or ''}",
|
||||
translation_key=f"train_number{i or ''}",
|
||||
value_fn=lambda data_connection: data_connection.train_number,
|
||||
index=i,
|
||||
)
|
||||
for i in range(DEPARTURES_COUNT)
|
||||
],
|
||||
*[
|
||||
IsraelRailSensorEntityDescription(
|
||||
key=f"departure_delay{i or ''}",
|
||||
translation_key=f"departure_delay{i or ''}",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda data_connection: data_connection.departure_delay,
|
||||
index=i,
|
||||
)
|
||||
for i in range(DEPARTURES_COUNT)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -31,14 +31,38 @@
|
||||
"departure_delay": {
|
||||
"name": "Departure delay"
|
||||
},
|
||||
"departure_delay1": {
|
||||
"name": "Departure delay +1"
|
||||
},
|
||||
"departure_delay2": {
|
||||
"name": "Departure delay +2"
|
||||
},
|
||||
"platform": {
|
||||
"name": "Platform"
|
||||
},
|
||||
"platform1": {
|
||||
"name": "Platform +1"
|
||||
},
|
||||
"platform2": {
|
||||
"name": "Platform +2"
|
||||
},
|
||||
"train_number": {
|
||||
"name": "Train number"
|
||||
},
|
||||
"train_number1": {
|
||||
"name": "Train number +1"
|
||||
},
|
||||
"train_number2": {
|
||||
"name": "Train number +2"
|
||||
},
|
||||
"trains": {
|
||||
"name": "Trains"
|
||||
},
|
||||
"trains1": {
|
||||
"name": "Trains +1"
|
||||
},
|
||||
"trains2": {
|
||||
"name": "Trains +2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
|
||||
def async_on_update(self, event: NodeProperty) -> None:
|
||||
"""Save brightness in the update event from the ISY Node."""
|
||||
if self._node.status not in (0, ISY_VALUE_UNKNOWN):
|
||||
self._last_brightness = self._node.status
|
||||
if self._node.uom == UOM_PERCENTAGE:
|
||||
self._last_brightness = round(self._node.status * 255.0 / 100.0)
|
||||
else:
|
||||
|
||||
@@ -279,10 +279,6 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity):
|
||||
if uom in (UOM_INDEX, UOM_ON_OFF):
|
||||
return cast(str, self.target.formatted)
|
||||
|
||||
# Check if this is an index type and get formatted value
|
||||
if uom == UOM_INDEX and hasattr(self.target, "formatted"):
|
||||
return cast(str, self.target.formatted)
|
||||
|
||||
# Handle ISY precision and rounding
|
||||
value = convert_isy_value_to_hass(value, uom, self.target.prec)
|
||||
if value is None:
|
||||
|
||||
@@ -88,6 +88,9 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = (
|
||||
dict.fromkeys(_holiday.type.name for _holiday in info.holidays)
|
||||
),
|
||||
},
|
||||
next_update_fn=lambda zmanim: (
|
||||
zmanim.candle_lighting or zmanim.havdalah or zmanim.shkia.local
|
||||
),
|
||||
),
|
||||
JewishCalendarSensorDescription(
|
||||
key="omer_count",
|
||||
|
||||
@@ -8,8 +8,6 @@ import datetime
|
||||
from functools import partial
|
||||
from random import random
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.labs import (
|
||||
EventLabsUpdatedData,
|
||||
async_is_preview_feature_enabled,
|
||||
@@ -34,7 +32,7 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
@@ -51,9 +49,11 @@ from homeassistant.util.unit_conversion import (
|
||||
)
|
||||
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .services import async_setup_services
|
||||
|
||||
COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
Platform.BUTTON,
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.FAN,
|
||||
Platform.EVENT,
|
||||
Platform.IMAGE,
|
||||
@@ -69,15 +69,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
|
||||
{
|
||||
vol.Required("field_1"): vol.Coerce(int),
|
||||
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
|
||||
vol.Optional("field_3"): vol.Coerce(int),
|
||||
vol.Optional("field_4"): vol.In(["forwards", "reverse"]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the demo environment."""
|
||||
@@ -87,24 +78,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def service_handler(call: ServiceCall | None = None) -> ServiceResponse:
|
||||
"""Do nothing."""
|
||||
return None
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"test_service_1",
|
||||
service_handler,
|
||||
SCHEMA_SERVICE_TEST_SERVICE_1,
|
||||
description_placeholders={
|
||||
"meep_1": "foo",
|
||||
"meep_2": "bar",
|
||||
"meep_3": "beer",
|
||||
"meep_4": "milk",
|
||||
"meep_5": "https://example.com",
|
||||
},
|
||||
)
|
||||
async_setup_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Demo platform that has a couple of fake device trackers."""
|
||||
|
||||
from homeassistant.components.device_tracker import (
|
||||
BaseScannerEntity,
|
||||
SourceType,
|
||||
TrackerEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Everything but the Kitchen Sink config entry."""
|
||||
async_add_entities(
|
||||
[
|
||||
DemoTracker(
|
||||
unique_id="kitchen_sink_tracker_001",
|
||||
name="Demo tracker",
|
||||
latitude=hass.config.latitude,
|
||||
longitude=hass.config.longitude,
|
||||
accuracy=10,
|
||||
),
|
||||
DemoScanner(
|
||||
unique_id="kitchen_sink_scanner_001",
|
||||
name="Demo scanner",
|
||||
is_connected=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class DemoTracker(TrackerEntity):
|
||||
"""Representation of a demo tracker."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_source_type = SourceType.GPS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unique_id: str,
|
||||
name: str,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
accuracy: float,
|
||||
) -> None:
|
||||
"""Initialize the tracker."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_name = name
|
||||
self._attr_latitude = latitude
|
||||
self._attr_longitude = longitude
|
||||
self._attr_location_accuracy = accuracy
|
||||
|
||||
@callback
|
||||
def async_set_tracker_location(
|
||||
self, latitude: float, longitude: float, accuracy: float
|
||||
) -> None:
|
||||
"""Update the tracker location."""
|
||||
self._attr_latitude = latitude
|
||||
self._attr_longitude = longitude
|
||||
self._attr_location_accuracy = accuracy
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class DemoScanner(BaseScannerEntity):
|
||||
"""Representation of a demo scanner."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_source_type = SourceType.ROUTER
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unique_id: str,
|
||||
name: str,
|
||||
is_connected: bool,
|
||||
) -> None:
|
||||
"""Initialize the scanner."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_name = name
|
||||
self._is_connected = is_connected
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return true if the device is connected."""
|
||||
return self._is_connected
|
||||
|
||||
@callback
|
||||
def async_set_scanner_connected(self, connected: bool) -> None:
|
||||
"""Update the scanner connected state."""
|
||||
self._is_connected = connected
|
||||
self.async_write_ha_state()
|
||||
@@ -9,6 +9,12 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_scanner_connected": {
|
||||
"service": "mdi:lan-connect"
|
||||
},
|
||||
"set_tracker_location": {
|
||||
"service": "mdi:map-marker"
|
||||
},
|
||||
"test_service_1": {
|
||||
"sections": {
|
||||
"additional_fields": "mdi:test-tube"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Services for the Everything but the Kitchen Sink integration."""
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
|
||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
|
||||
{
|
||||
vol.Required("field_1"): vol.Coerce(int),
|
||||
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
|
||||
vol.Optional("field_3"): vol.Coerce(int),
|
||||
vol.Optional("field_4"): vol.In(["forward", "reverse"]),
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_TEST_SERVICE_1 = "test_service_1"
|
||||
SERVICE_SET_TRACKER_LOCATION = "set_tracker_location"
|
||||
SERVICE_SET_SCANNER_CONNECTED = "set_scanner_connected"
|
||||
|
||||
ATTR_ACCURACY = "accuracy"
|
||||
ATTR_CONNECTED = "connected"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for the Kitchen Sink integration."""
|
||||
|
||||
@callback
|
||||
def service_handler(call: ServiceCall) -> ServiceResponse:
|
||||
"""Do nothing."""
|
||||
return None
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_TEST_SERVICE_1,
|
||||
service_handler,
|
||||
SCHEMA_SERVICE_TEST_SERVICE_1,
|
||||
description_placeholders={
|
||||
"meep_1": "foo",
|
||||
"meep_2": "bar",
|
||||
"meep_3": "beer",
|
||||
"meep_4": "milk",
|
||||
"meep_5": "https://example.com",
|
||||
},
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_TRACKER_LOCATION,
|
||||
entity_domain=DEVICE_TRACKER_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_LATITUDE): cv.latitude,
|
||||
vol.Required(ATTR_LONGITUDE): cv.longitude,
|
||||
vol.Required(ATTR_ACCURACY): vol.All(vol.Coerce(float), vol.Range(min=0)),
|
||||
},
|
||||
func="async_set_tracker_location",
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_SCANNER_CONNECTED,
|
||||
entity_domain=DEVICE_TRACKER_DOMAIN,
|
||||
schema={vol.Required(ATTR_CONNECTED): cv.boolean},
|
||||
func="async_set_scanner_connected",
|
||||
)
|
||||
@@ -30,3 +30,44 @@ test_service_1:
|
||||
options:
|
||||
- "forward"
|
||||
- "reverse"
|
||||
set_tracker_location:
|
||||
target:
|
||||
entity:
|
||||
integration: kitchen_sink
|
||||
domain: device_tracker
|
||||
fields:
|
||||
latitude:
|
||||
required: true
|
||||
example: 52.379189
|
||||
selector:
|
||||
number:
|
||||
min: -90
|
||||
max: 90
|
||||
step: any
|
||||
longitude:
|
||||
required: true
|
||||
example: 4.899431
|
||||
selector:
|
||||
number:
|
||||
min: -180
|
||||
max: 180
|
||||
step: any
|
||||
accuracy:
|
||||
required: true
|
||||
example: 10
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 10000
|
||||
unit_of_measurement: m
|
||||
set_scanner_connected:
|
||||
target:
|
||||
entity:
|
||||
integration: kitchen_sink
|
||||
domain: device_tracker
|
||||
fields:
|
||||
connected:
|
||||
required: true
|
||||
example: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
@@ -135,6 +135,34 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_scanner_connected": {
|
||||
"description": "Sets the connected state of a demo scanner entity.",
|
||||
"fields": {
|
||||
"connected": {
|
||||
"description": "Whether the device should be reported as connected.",
|
||||
"name": "Connected"
|
||||
}
|
||||
},
|
||||
"name": "Set scanner connected"
|
||||
},
|
||||
"set_tracker_location": {
|
||||
"description": "Sets the location and accuracy of a demo tracker entity.",
|
||||
"fields": {
|
||||
"accuracy": {
|
||||
"description": "Location accuracy in meters.",
|
||||
"name": "Accuracy"
|
||||
},
|
||||
"latitude": {
|
||||
"description": "Latitude of the new location.",
|
||||
"name": "Latitude"
|
||||
},
|
||||
"longitude": {
|
||||
"description": "Longitude of the new location.",
|
||||
"name": "Longitude"
|
||||
}
|
||||
},
|
||||
"name": "Set tracker location"
|
||||
},
|
||||
"test_service_1": {
|
||||
"description": "Fake action for testing {meep_2}",
|
||||
"fields": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user