mirror of
https://github.com/home-assistant/core.git
synced 2026-05-27 19:25:18 +02:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b28e6502a3 | |||
| e3aafaedb1 | |||
| 9f32319481 | |||
| ddd9c5ab61 | |||
| 4936885598 | |||
| 67fff835b2 | |||
| 7b19a3a71b | |||
| 7994744bea | |||
| e9e5bda3f6 | |||
| 3d807de32d | |||
| fa60ef5477 | |||
| 3046996869 | |||
| 9930d7dad4 | |||
| e18dd7e906 | |||
| d12fb7814a | |||
| 8e6be68fe3 | |||
| c1a71bed25 | |||
| ee82ca9677 | |||
| b51067d37d | |||
| 12f24ac6bf | |||
| 6b92011cae | |||
| c88253752f | |||
| 4f43b99540 | |||
| 8f1a294efe | |||
| f07d650de8 | |||
| f494fa2909 | |||
| b81a221c20 | |||
| f852c33cf8 | |||
| 7b60f912a7 | |||
| da978415a8 | |||
| 64750386cb | |||
| 0c45d006f7 | |||
| cd81c61509 | |||
| 81bca02aed | |||
| cc2428c2b5 |
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"features": {
|
|
||||||
"ghcr.io/devcontainers/features/github-cli:1": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
|
|
||||||
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
name: Set up uv and managed Python
|
|
||||||
description: >-
|
|
||||||
Pins uv (avoids the raw.githubusercontent.com manifest fetch on cache miss)
|
|
||||||
and proactively installs the requested Python so cached venvs created with
|
|
||||||
`uv venv` resolve their interpreter symlinks in jobs that only restore the
|
|
||||||
venv. setup-uv alone only sets UV_PYTHON, it does not actually fetch the
|
|
||||||
interpreter until uv first uses it, so jobs that just activate the venv
|
|
||||||
blow up with broken symlinks on cache hit.
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
python-version:
|
|
||||||
description: The Python version uv should install and use.
|
|
||||||
required: true
|
|
||||||
uv-version:
|
|
||||||
description: The uv version setup-uv should install.
|
|
||||||
required: true
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
python-version:
|
|
||||||
description: The Python version uv reports as installed.
|
|
||||||
value: ${{ steps.uv.outputs.python-version }}
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- name: Set up uv
|
|
||||||
id: uv
|
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
||||||
with:
|
|
||||||
version: ${{ inputs.uv-version }}
|
|
||||||
python-version: ${{ inputs.python-version }}
|
|
||||||
# Persist astral's managed Python across jobs so 'uv venv' below is
|
|
||||||
# fast on the second job onwards.
|
|
||||||
cache-python: true
|
|
||||||
# Lint-only and codegen jobs touch no Python deps, so the post-step
|
|
||||||
# cache save would otherwise abort the job.
|
|
||||||
ignore-nothing-to-cache: true
|
|
||||||
- name: Install Python interpreter
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
|
||||||
run: uv python install "${PYTHON_VERSION}"
|
|
||||||
+245
-136
@@ -37,7 +37,7 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CACHE_VERSION: 4
|
CACHE_VERSION: 3
|
||||||
MYPY_CACHE_VERSION: 1
|
MYPY_CACHE_VERSION: 1
|
||||||
HA_SHORT_VERSION: "2026.6"
|
HA_SHORT_VERSION: "2026.6"
|
||||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||||
@@ -60,7 +60,9 @@ env:
|
|||||||
# - 15.2 is the latest (as of 9 Feb 2023)
|
# - 15.2 is the latest (as of 9 Feb 2023)
|
||||||
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
|
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
|
||||||
UV_CACHE_DIR: /tmp/uv-cache
|
UV_CACHE_DIR: /tmp/uv-cache
|
||||||
APT_CACHE_VERSION: 1
|
APT_CACHE_BASE: /home/runner/work/apt
|
||||||
|
APT_CACHE_DIR: /home/runner/work/apt/cache
|
||||||
|
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
|
||||||
SQLALCHEMY_WARN_20: 1
|
SQLALCHEMY_WARN_20: 1
|
||||||
PYTHONASYNCIODEBUG: 1
|
PYTHONASYNCIODEBUG: 1
|
||||||
HASS_CI: 1
|
HASS_CI: 1
|
||||||
@@ -84,13 +86,12 @@ jobs:
|
|||||||
core: ${{ steps.core.outputs.changes }}
|
core: ${{ steps.core.outputs.changes }}
|
||||||
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
|
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
|
||||||
integrations: ${{ steps.integrations.outputs.changes }}
|
integrations: ${{ steps.integrations.outputs.changes }}
|
||||||
|
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
|
||||||
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
|
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
|
||||||
requirements: ${{ steps.core.outputs.requirements }}
|
requirements: ${{ steps.core.outputs.requirements }}
|
||||||
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
|
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
|
||||||
postgresql_groups: ${{ steps.info.outputs.postgresql_groups }}
|
postgresql_groups: ${{ steps.info.outputs.postgresql_groups }}
|
||||||
python_versions: ${{ steps.info.outputs.python_versions }}
|
python_versions: ${{ steps.info.outputs.python_versions }}
|
||||||
default_python: ${{ steps.info.outputs.default_python }}
|
|
||||||
uv_version: ${{ steps.info.outputs.uv_version }}
|
|
||||||
test_full_suite: ${{ steps.info.outputs.test_full_suite }}
|
test_full_suite: ${{ steps.info.outputs.test_full_suite }}
|
||||||
test_group_count: ${{ steps.info.outputs.test_group_count }}
|
test_group_count: ${{ steps.info.outputs.test_group_count }}
|
||||||
test_groups: ${{ steps.info.outputs.test_groups }}
|
test_groups: ${{ steps.info.outputs.test_groups }}
|
||||||
@@ -115,6 +116,10 @@ jobs:
|
|||||||
# Include HA_SHORT_VERSION to force the immediate creation
|
# Include HA_SHORT_VERSION to force the immediate creation
|
||||||
# of a new uv cache entry after a version bump.
|
# of a new uv cache entry after a version bump.
|
||||||
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
|
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
|
||||||
|
- name: Generate partial apt restore key
|
||||||
|
id: generate_apt_cache_key
|
||||||
|
run: |
|
||||||
|
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
|
||||||
- name: Filter for core changes
|
- name: Filter for core changes
|
||||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||||
id: core
|
id: core
|
||||||
@@ -237,11 +242,6 @@ jobs:
|
|||||||
echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT
|
echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT
|
||||||
echo "python_versions: ${all_python_versions}"
|
echo "python_versions: ${all_python_versions}"
|
||||||
echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT
|
echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT
|
||||||
echo "default_python: ${default_python}"
|
|
||||||
echo "default_python=${default_python}" >> $GITHUB_OUTPUT
|
|
||||||
uv_version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)
|
|
||||||
echo "uv_version: ${uv_version}"
|
|
||||||
echo "uv_version=${uv_version}" >> $GITHUB_OUTPUT
|
|
||||||
echo "test_full_suite: ${test_full_suite}"
|
echo "test_full_suite: ${test_full_suite}"
|
||||||
echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT
|
echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT
|
||||||
echo "integrations_glob: ${integrations_glob}"
|
echo "integrations_glob: ${integrations_glob}"
|
||||||
@@ -351,12 +351,12 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up uv and Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
check-latest: true
|
||||||
- name: Restore base Python virtual environment
|
- name: Restore base Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
@@ -384,40 +384,80 @@ jobs:
|
|||||||
path: ${{ env.UV_CACHE_DIR }}
|
path: ${{ env.UV_CACHE_DIR }}
|
||||||
key: ${{ steps.generate-uv-key.outputs.full_key }}
|
key: ${{ steps.generate-uv-key.outputs.full_key }}
|
||||||
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
|
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
|
||||||
- name: Install additional OS dependencies
|
- name: Check if apt cache exists
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
id: cache-apt-check
|
||||||
timeout-minutes: 10
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
uses: ./.github/actions/cache-apt-packages
|
|
||||||
with:
|
with:
|
||||||
packages: >-
|
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
|
||||||
bluez
|
path: |
|
||||||
ffmpeg
|
${{ env.APT_CACHE_DIR }}
|
||||||
libturbojpeg
|
${{ env.APT_LIST_CACHE_DIR }}
|
||||||
libxml2-utils
|
key: >-
|
||||||
libavcodec-dev
|
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||||
libavdevice-dev
|
- name: Install additional OS dependencies
|
||||||
libavfilter-dev
|
if: |
|
||||||
libavformat-dev
|
steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
libavutil-dev
|
|| steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||||
libswresample-dev
|
id: install-os-deps
|
||||||
libswscale-dev
|
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
|
libudev-dev
|
||||||
version: ${{ env.APT_CACHE_VERSION }}
|
|
||||||
execute_install_scripts: true
|
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
|
||||||
|
sudo chmod -R 755 ${APT_CACHE_BASE}
|
||||||
|
fi
|
||||||
|
- name: Save apt cache
|
||||||
|
if: |
|
||||||
|
always()
|
||||||
|
&& steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||||
|
&& steps.install-os-deps.outcome == 'success'
|
||||||
|
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.APT_CACHE_DIR }}
|
||||||
|
${{ env.APT_LIST_CACHE_DIR }}
|
||||||
|
key: >-
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||||
- name: Create Python virtual environment
|
- name: Create Python virtual environment
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
id: create-venv
|
id: create-venv
|
||||||
env:
|
|
||||||
PYTHON_VERSION: ${{ steps.python.outputs.python-version }}
|
|
||||||
run: |
|
run: |
|
||||||
uv venv venv --python "${PYTHON_VERSION}"
|
python -m venv venv
|
||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
python --version
|
python --version
|
||||||
|
pip install "$(grep '^uv' < requirements.txt)"
|
||||||
|
uv pip install -U "pip>=25.2"
|
||||||
uv pip install -r requirements.txt
|
uv pip install -r requirements.txt
|
||||||
uv pip install -r requirements_all.txt -r requirements_test.txt
|
uv pip install -r requirements_all.txt -r requirements_test.txt
|
||||||
uv pip install -e . --config-settings editable_mode=compat
|
uv pip install -e . --config-settings editable_mode=compat
|
||||||
- name: Dump pip freeze
|
- name: Dump pip freeze
|
||||||
run: |
|
run: |
|
||||||
|
python -m venv venv
|
||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
python --version
|
python --version
|
||||||
uv pip freeze >> pip_freeze.txt
|
uv pip freeze >> pip_freeze.txt
|
||||||
@@ -466,22 +506,36 @@ jobs:
|
|||||||
&& github.event.inputs.mypy-only != 'true'
|
&& github.event.inputs.mypy-only != 'true'
|
||||||
&& github.event.inputs.audit-licenses-only != 'true'
|
&& github.event.inputs.audit-licenses-only != 'true'
|
||||||
steps:
|
steps:
|
||||||
|
- name: Restore apt cache
|
||||||
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.APT_CACHE_DIR }}
|
||||||
|
${{ env.APT_LIST_CACHE_DIR }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
key: >-
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||||
|
- name: Install additional OS dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: |
|
||||||
|
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||||
|
sudo apt-get update \
|
||||||
|
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||||
|
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||||
|
sudo apt-get -y install \
|
||||||
|
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||||
|
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||||
|
libturbojpeg
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install additional OS dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
uses: ./.github/actions/cache-apt-packages
|
|
||||||
with:
|
|
||||||
packages: libturbojpeg
|
|
||||||
version: ${{ env.APT_CACHE_VERSION }}
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
python-version-file: ".python-version"
|
||||||
python-version: ${{ needs.info.outputs.default_python }}
|
check-latest: true
|
||||||
- name: Restore full Python virtual environment
|
- name: Restore full Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
@@ -515,10 +569,10 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
python-version-file: ".python-version"
|
||||||
python-version: ${{ needs.info.outputs.default_python }}
|
check-latest: true
|
||||||
- name: Restore full Python virtual environment
|
- name: Restore full Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
@@ -551,10 +605,10 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
python-version-file: ".python-version"
|
||||||
python-version: ${{ needs.info.outputs.default_python }}
|
check-latest: true
|
||||||
- name: Run gen_copilot_instructions.py
|
- name: Run gen_copilot_instructions.py
|
||||||
run: |
|
run: |
|
||||||
python -m script.gen_copilot_instructions validate
|
python -m script.gen_copilot_instructions validate
|
||||||
@@ -606,10 +660,10 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
check-latest: true
|
||||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
@@ -657,10 +711,10 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
python-version-file: ".python-version"
|
||||||
python-version: ${{ needs.info.outputs.default_python }}
|
check-latest: true
|
||||||
- name: Restore full Python virtual environment
|
- name: Restore full Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
@@ -710,10 +764,10 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
python-version-file: ".python-version"
|
||||||
python-version: ${{ needs.info.outputs.default_python }}
|
check-latest: true
|
||||||
- name: Restore full Python virtual environment
|
- name: Restore full Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
@@ -761,10 +815,10 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
python-version-file: ".python-version"
|
||||||
python-version: ${{ needs.info.outputs.default_python }}
|
check-latest: true
|
||||||
- name: Generate partial mypy restore key
|
- name: Generate partial mypy restore key
|
||||||
id: generate-mypy-key
|
id: generate-mypy-key
|
||||||
run: |
|
run: |
|
||||||
@@ -822,26 +876,38 @@ jobs:
|
|||||||
- info
|
- info
|
||||||
- base
|
- base
|
||||||
steps:
|
steps:
|
||||||
|
- name: Restore apt cache
|
||||||
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.APT_CACHE_DIR }}
|
||||||
|
${{ env.APT_LIST_CACHE_DIR }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
key: >-
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||||
|
- name: Install additional OS dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: |
|
||||||
|
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||||
|
sudo apt-get update \
|
||||||
|
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||||
|
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||||
|
sudo apt-get -y install \
|
||||||
|
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||||
|
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||||
|
bluez \
|
||||||
|
ffmpeg \
|
||||||
|
libturbojpeg
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install additional OS dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
uses: ./.github/actions/cache-apt-packages
|
|
||||||
with:
|
|
||||||
packages: >-
|
|
||||||
bluez
|
|
||||||
ffmpeg
|
|
||||||
libturbojpeg
|
|
||||||
version: ${{ env.APT_CACHE_VERSION }}
|
|
||||||
execute_install_scripts: true
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
python-version-file: ".python-version"
|
||||||
python-version: ${{ needs.info.outputs.default_python }}
|
check-latest: true
|
||||||
- name: Restore full Python virtual environment
|
- name: Restore full Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
@@ -886,27 +952,39 @@ jobs:
|
|||||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Restore apt cache
|
||||||
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.APT_CACHE_DIR }}
|
||||||
|
${{ env.APT_LIST_CACHE_DIR }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
key: >-
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||||
|
- name: Install additional OS dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: |
|
||||||
|
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||||
|
sudo apt-get update \
|
||||||
|
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||||
|
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||||
|
sudo apt-get -y install \
|
||||||
|
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||||
|
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||||
|
bluez \
|
||||||
|
ffmpeg \
|
||||||
|
libturbojpeg \
|
||||||
|
libxml2-utils
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install additional OS dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
uses: ./.github/actions/cache-apt-packages
|
|
||||||
with:
|
|
||||||
packages: >-
|
|
||||||
bluez
|
|
||||||
ffmpeg
|
|
||||||
libturbojpeg
|
|
||||||
libxml2-utils
|
|
||||||
version: ${{ env.APT_CACHE_VERSION }}
|
|
||||||
execute_install_scripts: true
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
check-latest: true
|
||||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
@@ -1027,28 +1105,40 @@ jobs:
|
|||||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Restore apt cache
|
||||||
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.APT_CACHE_DIR }}
|
||||||
|
${{ env.APT_LIST_CACHE_DIR }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
key: >-
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||||
|
- name: Install additional OS dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: |
|
||||||
|
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||||
|
sudo apt-get update \
|
||||||
|
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||||
|
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||||
|
sudo apt-get -y install \
|
||||||
|
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||||
|
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||||
|
bluez \
|
||||||
|
ffmpeg \
|
||||||
|
libturbojpeg \
|
||||||
|
libmariadb-dev-compat \
|
||||||
|
libxml2-utils
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install additional OS dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
uses: ./.github/actions/cache-apt-packages
|
|
||||||
with:
|
|
||||||
packages: >-
|
|
||||||
bluez
|
|
||||||
ffmpeg
|
|
||||||
libturbojpeg
|
|
||||||
libmariadb-dev-compat
|
|
||||||
libxml2-utils
|
|
||||||
version: ${{ env.APT_CACHE_VERSION }}
|
|
||||||
execute_install_scripts: true
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
check-latest: true
|
||||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
@@ -1176,35 +1266,42 @@ jobs:
|
|||||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Restore apt cache
|
||||||
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.APT_CACHE_DIR }}
|
||||||
|
${{ env.APT_LIST_CACHE_DIR }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
key: >-
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||||
|
- name: Install additional OS dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: |
|
||||||
|
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||||
|
sudo apt-get update \
|
||||||
|
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||||
|
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||||
|
sudo apt-get -y install \
|
||||||
|
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||||
|
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||||
|
bluez \
|
||||||
|
ffmpeg \
|
||||||
|
libturbojpeg \
|
||||||
|
libxml2-utils
|
||||||
|
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||||
|
sudo apt-get -y install \
|
||||||
|
postgresql-server-dev-14
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install additional OS dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
uses: ./.github/actions/cache-apt-packages
|
|
||||||
with:
|
|
||||||
packages: >-
|
|
||||||
bluez
|
|
||||||
ffmpeg
|
|
||||||
libturbojpeg
|
|
||||||
libxml2-utils
|
|
||||||
version: ${{ env.APT_CACHE_VERSION }}
|
|
||||||
execute_install_scripts: true
|
|
||||||
- name: Set up PostgreSQL apt repository
|
|
||||||
run: sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
|
||||||
- name: Cache PostgreSQL development headers
|
|
||||||
timeout-minutes: 10
|
|
||||||
uses: ./.github/actions/cache-apt-packages
|
|
||||||
with:
|
|
||||||
packages: postgresql-server-dev-14
|
|
||||||
version: ${{ env.APT_CACHE_VERSION }}
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
check-latest: true
|
||||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
@@ -1324,7 +1421,7 @@ jobs:
|
|||||||
pattern: coverage-*
|
pattern: coverage-*
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
if: needs.info.outputs.test_full_suite == 'true'
|
if: needs.info.outputs.test_full_suite == 'true'
|
||||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
with:
|
with:
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
flags: full-suite
|
flags: full-suite
|
||||||
@@ -1352,27 +1449,39 @@ jobs:
|
|||||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Restore apt cache
|
||||||
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.APT_CACHE_DIR }}
|
||||||
|
${{ env.APT_LIST_CACHE_DIR }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
key: >-
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||||
|
- name: Install additional OS dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: |
|
||||||
|
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||||
|
sudo apt-get update \
|
||||||
|
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||||
|
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||||
|
sudo apt-get -y install \
|
||||||
|
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||||
|
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||||
|
bluez \
|
||||||
|
ffmpeg \
|
||||||
|
libturbojpeg \
|
||||||
|
libxml2-utils
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install additional OS dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
uses: ./.github/actions/cache-apt-packages
|
|
||||||
with:
|
|
||||||
packages: >-
|
|
||||||
bluez
|
|
||||||
ffmpeg
|
|
||||||
libturbojpeg
|
|
||||||
libxml2-utils
|
|
||||||
version: ${{ env.APT_CACHE_VERSION }}
|
|
||||||
execute_install_scripts: true
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: ./.github/actions/setup-uv-python
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
check-latest: true
|
||||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
@@ -1483,7 +1592,7 @@ jobs:
|
|||||||
pattern: coverage-*
|
pattern: coverage-*
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
if: needs.info.outputs.test_full_suite == 'false'
|
if: needs.info.outputs.test_full_suite == 'false'
|
||||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
with:
|
with:
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||||
@@ -1511,7 +1620,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
pattern: test-results-*
|
pattern: test-results-*
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
with:
|
with:
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ jobs:
|
|||||||
- name: Generate app token
|
- name: Generate app token
|
||||||
id: token
|
id: token
|
||||||
# Pinned to a specific version of the action for security reasons
|
# Pinned to a specific version of the action for security reasons
|
||||||
# v3.2.0
|
# v1.7.0
|
||||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||||
|
|
||||||
# The 90 day stale policy for issues
|
# The 90 day stale policy for issues
|
||||||
# Used for:
|
# Used for:
|
||||||
|
|||||||
@@ -337,7 +337,6 @@ homeassistant.components.led_ble.*
|
|||||||
homeassistant.components.lektrico.*
|
homeassistant.components.lektrico.*
|
||||||
homeassistant.components.letpot.*
|
homeassistant.components.letpot.*
|
||||||
homeassistant.components.lg_infrared.*
|
homeassistant.components.lg_infrared.*
|
||||||
homeassistant.components.lg_tv_rs232.*
|
|
||||||
homeassistant.components.libre_hardware_monitor.*
|
homeassistant.components.libre_hardware_monitor.*
|
||||||
homeassistant.components.lidarr.*
|
homeassistant.components.lidarr.*
|
||||||
homeassistant.components.liebherr.*
|
homeassistant.components.liebherr.*
|
||||||
@@ -429,7 +428,6 @@ homeassistant.components.otp.*
|
|||||||
homeassistant.components.ouman_eh_800.*
|
homeassistant.components.ouman_eh_800.*
|
||||||
homeassistant.components.overkiz.*
|
homeassistant.components.overkiz.*
|
||||||
homeassistant.components.overseerr.*
|
homeassistant.components.overseerr.*
|
||||||
homeassistant.components.ovhcloud_ai_endpoints.*
|
|
||||||
homeassistant.components.p1_monitor.*
|
homeassistant.components.p1_monitor.*
|
||||||
homeassistant.components.paj_gps.*
|
homeassistant.components.paj_gps.*
|
||||||
homeassistant.components.panel_custom.*
|
homeassistant.components.panel_custom.*
|
||||||
@@ -567,7 +565,6 @@ homeassistant.components.technove.*
|
|||||||
homeassistant.components.tedee.*
|
homeassistant.components.tedee.*
|
||||||
homeassistant.components.telegram_bot.*
|
homeassistant.components.telegram_bot.*
|
||||||
homeassistant.components.teleinfo.*
|
homeassistant.components.teleinfo.*
|
||||||
homeassistant.components.teltonika.*
|
|
||||||
homeassistant.components.teslemetry.*
|
homeassistant.components.teslemetry.*
|
||||||
homeassistant.components.text.*
|
homeassistant.components.text.*
|
||||||
homeassistant.components.thethingsnetwork.*
|
homeassistant.components.thethingsnetwork.*
|
||||||
@@ -612,7 +609,6 @@ homeassistant.components.valve.*
|
|||||||
homeassistant.components.velbus.*
|
homeassistant.components.velbus.*
|
||||||
homeassistant.components.velux.*
|
homeassistant.components.velux.*
|
||||||
homeassistant.components.victron_gx.*
|
homeassistant.components.victron_gx.*
|
||||||
homeassistant.components.vistapool.*
|
|
||||||
homeassistant.components.vivotek.*
|
homeassistant.components.vivotek.*
|
||||||
homeassistant.components.vlc_telnet.*
|
homeassistant.components.vlc_telnet.*
|
||||||
homeassistant.components.vodafone_station.*
|
homeassistant.components.vodafone_station.*
|
||||||
|
|||||||
Generated
+2
-12
@@ -236,8 +236,8 @@ CLAUDE.md @home-assistant/core
|
|||||||
/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx
|
/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx
|
||||||
/homeassistant/components/blink/ @fronzbot
|
/homeassistant/components/blink/ @fronzbot
|
||||||
/tests/components/blink/ @fronzbot
|
/tests/components/blink/ @fronzbot
|
||||||
/homeassistant/components/blue_current/ @gleeuwen @jtodorova23
|
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||||
/tests/components/blue_current/ @gleeuwen @jtodorova23
|
/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||||
/homeassistant/components/bluemaestro/ @bdraco
|
/homeassistant/components/bluemaestro/ @bdraco
|
||||||
/tests/components/bluemaestro/ @bdraco
|
/tests/components/bluemaestro/ @bdraco
|
||||||
/homeassistant/components/blueprint/ @home-assistant/core
|
/homeassistant/components/blueprint/ @home-assistant/core
|
||||||
@@ -987,8 +987,6 @@ CLAUDE.md @home-assistant/core
|
|||||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||||
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
||||||
/tests/components/lg_thinq/ @LG-ThinQ-Integration
|
/tests/components/lg_thinq/ @LG-ThinQ-Integration
|
||||||
/homeassistant/components/lg_tv_rs232/ @balloob
|
|
||||||
/tests/components/lg_tv_rs232/ @balloob
|
|
||||||
/homeassistant/components/libre_hardware_monitor/ @Sab44
|
/homeassistant/components/libre_hardware_monitor/ @Sab44
|
||||||
/tests/components/libre_hardware_monitor/ @Sab44
|
/tests/components/libre_hardware_monitor/ @Sab44
|
||||||
/homeassistant/components/lichess/ @aryanhasgithub
|
/homeassistant/components/lichess/ @aryanhasgithub
|
||||||
@@ -1292,8 +1290,6 @@ CLAUDE.md @home-assistant/core
|
|||||||
/tests/components/openhome/ @bazwilliams
|
/tests/components/openhome/ @bazwilliams
|
||||||
/homeassistant/components/openrgb/ @felipecrs
|
/homeassistant/components/openrgb/ @felipecrs
|
||||||
/tests/components/openrgb/ @felipecrs
|
/tests/components/openrgb/ @felipecrs
|
||||||
/homeassistant/components/opensensemap/ @AlCalzone
|
|
||||||
/tests/components/opensensemap/ @AlCalzone
|
|
||||||
/homeassistant/components/opensky/ @joostlek
|
/homeassistant/components/opensky/ @joostlek
|
||||||
/tests/components/opensky/ @joostlek
|
/tests/components/opensky/ @joostlek
|
||||||
/homeassistant/components/opentherm_gw/ @mvn23
|
/homeassistant/components/opentherm_gw/ @mvn23
|
||||||
@@ -1321,8 +1317,6 @@ CLAUDE.md @home-assistant/core
|
|||||||
/tests/components/overkiz/ @imicknl
|
/tests/components/overkiz/ @imicknl
|
||||||
/homeassistant/components/overseerr/ @joostlek @AmGarera
|
/homeassistant/components/overseerr/ @joostlek @AmGarera
|
||||||
/tests/components/overseerr/ @joostlek @AmGarera
|
/tests/components/overseerr/ @joostlek @AmGarera
|
||||||
/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon
|
|
||||||
/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon
|
|
||||||
/homeassistant/components/ovo_energy/ @timmo001
|
/homeassistant/components/ovo_energy/ @timmo001
|
||||||
/tests/components/ovo_energy/ @timmo001
|
/tests/components/ovo_energy/ @timmo001
|
||||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||||
@@ -1936,8 +1930,6 @@ CLAUDE.md @home-assistant/core
|
|||||||
/tests/components/victron_remote_monitoring/ @AndyTempel
|
/tests/components/victron_remote_monitoring/ @AndyTempel
|
||||||
/homeassistant/components/vilfo/ @ManneW
|
/homeassistant/components/vilfo/ @ManneW
|
||||||
/tests/components/vilfo/ @ManneW
|
/tests/components/vilfo/ @ManneW
|
||||||
/homeassistant/components/vistapool/ @fdebrus
|
|
||||||
/tests/components/vistapool/ @fdebrus
|
|
||||||
/homeassistant/components/vivotek/ @HarlemSquirrel
|
/homeassistant/components/vivotek/ @HarlemSquirrel
|
||||||
/tests/components/vivotek/ @HarlemSquirrel
|
/tests/components/vivotek/ @HarlemSquirrel
|
||||||
/homeassistant/components/vizio/ @raman325
|
/homeassistant/components/vizio/ @raman325
|
||||||
@@ -2062,8 +2054,6 @@ CLAUDE.md @home-assistant/core
|
|||||||
/homeassistant/components/yi/ @bachya
|
/homeassistant/components/yi/ @bachya
|
||||||
/homeassistant/components/yolink/ @matrixd2
|
/homeassistant/components/yolink/ @matrixd2
|
||||||
/tests/components/yolink/ @matrixd2
|
/tests/components/yolink/ @matrixd2
|
||||||
/homeassistant/components/yoto/ @cdnninja @piitaya
|
|
||||||
/tests/components/yoto/ @cdnninja @piitaya
|
|
||||||
/homeassistant/components/youless/ @gjong
|
/homeassistant/components/youless/ @gjong
|
||||||
/tests/components/youless/ @gjong
|
/tests/components/youless/ @gjong
|
||||||
/homeassistant/components/youtube/ @joostlek
|
/homeassistant/components/youtube/ @joostlek
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ class AuthManager:
|
|||||||
token_type: str | None = None,
|
token_type: str | None = None,
|
||||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||||
credential: models.Credentials | None = None,
|
credential: models.Credentials | None = None,
|
||||||
|
scopes: frozenset[str] | None = None,
|
||||||
) -> models.RefreshToken:
|
) -> models.RefreshToken:
|
||||||
"""Create a new refresh token for a user."""
|
"""Create a new refresh token for a user."""
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
@@ -514,6 +515,7 @@ class AuthManager:
|
|||||||
access_token_expiration,
|
access_token_expiration,
|
||||||
expire_at,
|
expire_at,
|
||||||
credential,
|
credential,
|
||||||
|
scopes,
|
||||||
)
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ class AuthStore:
|
|||||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||||
expire_at: float | None = None,
|
expire_at: float | None = None,
|
||||||
credential: models.Credentials | None = None,
|
credential: models.Credentials | None = None,
|
||||||
|
scopes: frozenset[str] | None = None,
|
||||||
) -> models.RefreshToken:
|
) -> models.RefreshToken:
|
||||||
"""Create a new token for a user."""
|
"""Create a new token for a user."""
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
@@ -220,6 +221,7 @@ class AuthStore:
|
|||||||
"access_token_expiration": access_token_expiration,
|
"access_token_expiration": access_token_expiration,
|
||||||
"expire_at": expire_at,
|
"expire_at": expire_at,
|
||||||
"credential": credential,
|
"credential": credential,
|
||||||
|
"scopes": scopes,
|
||||||
}
|
}
|
||||||
if client_name:
|
if client_name:
|
||||||
kwargs["client_name"] = client_name
|
kwargs["client_name"] = client_name
|
||||||
@@ -475,6 +477,7 @@ class AuthStore:
|
|||||||
else:
|
else:
|
||||||
last_used_at = None
|
last_used_at = None
|
||||||
|
|
||||||
|
scopes = rt_dict.get("scopes")
|
||||||
token = models.RefreshToken(
|
token = models.RefreshToken(
|
||||||
id=rt_dict["id"],
|
id=rt_dict["id"],
|
||||||
user=users[rt_dict["user_id"]],
|
user=users[rt_dict["user_id"]],
|
||||||
@@ -493,6 +496,7 @@ class AuthStore:
|
|||||||
last_used_ip=rt_dict.get("last_used_ip"),
|
last_used_ip=rt_dict.get("last_used_ip"),
|
||||||
expire_at=rt_dict.get("expire_at"),
|
expire_at=rt_dict.get("expire_at"),
|
||||||
version=rt_dict.get("version"),
|
version=rt_dict.get("version"),
|
||||||
|
scopes=frozenset(scopes) if scopes else None,
|
||||||
)
|
)
|
||||||
if "credential_id" in rt_dict:
|
if "credential_id" in rt_dict:
|
||||||
token.credential = credentials.get(rt_dict["credential_id"])
|
token.credential = credentials.get(rt_dict["credential_id"])
|
||||||
@@ -581,6 +585,9 @@ class AuthStore:
|
|||||||
if refresh_token.credential
|
if refresh_token.credential
|
||||||
else None,
|
else None,
|
||||||
"version": refresh_token.version,
|
"version": refresh_token.version,
|
||||||
|
"scopes": sorted(refresh_token.scopes)
|
||||||
|
if refresh_token.scopes is not None
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
for user in self._users.values()
|
for user in self._users.values()
|
||||||
for refresh_token in user.refresh_tokens.values()
|
for refresh_token in user.refresh_tokens.values()
|
||||||
|
|||||||
@@ -129,6 +129,13 @@ class RefreshToken:
|
|||||||
|
|
||||||
version: str | None = attr.ib(default=__version__)
|
version: str | None = attr.ib(default=__version__)
|
||||||
|
|
||||||
|
# Optional set of websocket-API command scopes. ``None`` means the token
|
||||||
|
# has no scope restriction (the default for normal user/system tokens).
|
||||||
|
# When set, the token may only call commands matching an entry in the
|
||||||
|
# set: a scope ending in ``/`` matches any command whose type starts
|
||||||
|
# with the prefix; otherwise the scope is an exact ``type`` match.
|
||||||
|
scopes: frozenset[str] | None = attr.ib(default=None)
|
||||||
|
|
||||||
|
|
||||||
@attr.s(slots=True)
|
@attr.s(slots=True)
|
||||||
class Credentials:
|
class Credentials:
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
"""Alexa Devices integration."""
|
"""Alexa Devices integration."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
|
||||||
|
|
||||||
from homeassistant.const import CONF_COUNTRY, Platform
|
from homeassistant.const import CONF_COUNTRY, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client
|
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
|
|
||||||
|
|
||||||
from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN
|
from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN
|
||||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||||
@@ -16,8 +12,6 @@ from .services import async_setup_services
|
|||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
Platform.BUTTON,
|
Platform.BUTTON,
|
||||||
Platform.EVENT,
|
|
||||||
Platform.MEDIA_PLAYER,
|
|
||||||
Platform.NOTIFY,
|
Platform.NOTIFY,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
@@ -40,28 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
|||||||
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
await coordinator.sync_history_state()
|
|
||||||
await coordinator.sync_media_state()
|
|
||||||
|
|
||||||
async def _on_http2_reauth_required() -> None:
|
|
||||||
entry.async_start_reauth(hass)
|
|
||||||
|
|
||||||
async def _cancel_http2() -> None:
|
|
||||||
http2_task.cancel()
|
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
|
||||||
await http2_task
|
|
||||||
|
|
||||||
alexa_httpx_client = httpx_client.get_async_client(
|
|
||||||
hass,
|
|
||||||
alpn_protocols=SSL_ALPN_HTTP11_HTTP2,
|
|
||||||
)
|
|
||||||
|
|
||||||
http2_task = await coordinator.api.start_http2_processing(
|
|
||||||
alexa_httpx_client, on_reauth_required=_on_http2_reauth_required
|
|
||||||
)
|
|
||||||
|
|
||||||
entry.async_on_unload(_cancel_http2)
|
|
||||||
|
|
||||||
entry.runtime_data = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|||||||
@@ -8,18 +8,13 @@ from aioamazondevices.exceptions import (
|
|||||||
CannotConnect,
|
CannotConnect,
|
||||||
CannotRetrieveData,
|
CannotRetrieveData,
|
||||||
)
|
)
|
||||||
from aioamazondevices.structures import (
|
from aioamazondevices.structures import AmazonDevice
|
||||||
AmazonDevice,
|
|
||||||
AmazonMediaState,
|
|
||||||
AmazonVocalRecord,
|
|
||||||
AmazonVolumeState,
|
|
||||||
)
|
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
from homeassistant.helpers.debounce import Debouncer
|
from homeassistant.helpers.debounce import Debouncer
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
@@ -78,18 +73,6 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
|||||||
if routine.domain == Platform.BUTTON
|
if routine.domain == Platform.BUTTON
|
||||||
}
|
}
|
||||||
|
|
||||||
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]:
|
async def _async_update_data(self) -> dict[str, AmazonDevice]:
|
||||||
"""Update device data."""
|
"""Update device data."""
|
||||||
try:
|
try:
|
||||||
@@ -166,66 +149,3 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
|||||||
)
|
)
|
||||||
if entity_id:
|
if entity_id:
|
||||||
entity_registry.async_remove(entity_id)
|
entity_registry.async_remove(entity_id)
|
||||||
|
|
||||||
async def sync_history_state(self) -> None:
|
|
||||||
"""Sync history state."""
|
|
||||||
try:
|
|
||||||
self._vocal_records = await self.api.sync_history_state()
|
|
||||||
except CannotAuthenticate as e:
|
|
||||||
raise ConfigEntryAuthFailed(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="invalid_auth",
|
|
||||||
translation_placeholders={"error": repr(e)},
|
|
||||||
) from e
|
|
||||||
except CannotConnect as e:
|
|
||||||
raise ConfigEntryNotReady(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="cannot_connect_with_error",
|
|
||||||
translation_placeholders={"error": repr(e)},
|
|
||||||
) from e
|
|
||||||
except BaseException as e:
|
|
||||||
raise ConfigEntryNotReady(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="cannot_retrieve_data_with_error",
|
|
||||||
translation_placeholders={"error": repr(e)},
|
|
||||||
) from e
|
|
||||||
|
|
||||||
async def history_state_event_handler(
|
|
||||||
self, vocal_records: dict[str, AmazonVocalRecord]
|
|
||||||
) -> None:
|
|
||||||
"""Handle pushed vocal record events."""
|
|
||||||
self._vocal_records = {**self._vocal_records, **vocal_records}
|
|
||||||
self.async_update_listeners()
|
|
||||||
|
|
||||||
@property
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
"""Support for events."""
|
|
||||||
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from homeassistant.components.event import EventEntity, EventEntityDescription
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
|
|
||||||
from .const import _LOGGER
|
|
||||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
|
||||||
from .entity import AmazonEntity
|
|
||||||
|
|
||||||
# Coordinator is used to centralize the data updates
|
|
||||||
PARALLEL_UPDATES = 0
|
|
||||||
|
|
||||||
EVENTS: Final = {
|
|
||||||
EventEntityDescription(
|
|
||||||
key="voice_event",
|
|
||||||
translation_key="voice_event",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
EVENT_TYPE = "triggered"
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: AmazonConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up Alexa Devices events based on a config entry."""
|
|
||||||
coordinator = entry.runtime_data
|
|
||||||
|
|
||||||
known_devices: set[str] = set()
|
|
||||||
|
|
||||||
def _check_device() -> None:
|
|
||||||
current_devices = set(coordinator.data)
|
|
||||||
new_devices = current_devices - known_devices
|
|
||||||
if new_devices:
|
|
||||||
known_devices.update(new_devices)
|
|
||||||
async_add_entities(
|
|
||||||
AlexaVoiceEvent(coordinator, serial_num, event_desc)
|
|
||||||
for event_desc in EVENTS
|
|
||||||
for serial_num in new_devices
|
|
||||||
)
|
|
||||||
|
|
||||||
_check_device()
|
|
||||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
|
||||||
|
|
||||||
|
|
||||||
class AlexaVoiceEvent(AmazonEntity, EventEntity):
|
|
||||||
"""Representation of an Alexa voice event."""
|
|
||||||
|
|
||||||
_attr_event_types = [EVENT_TYPE]
|
|
||||||
coordinator: AmazonDevicesCoordinator
|
|
||||||
_last_seen_timestamp: int | None = None
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _handle_coordinator_update(self) -> None:
|
|
||||||
"""Handle updated data from the coordinator."""
|
|
||||||
|
|
||||||
if not (
|
|
||||||
vocal_record := self.coordinator.vocal_records.get(
|
|
||||||
self.device.serial_number
|
|
||||||
)
|
|
||||||
):
|
|
||||||
_LOGGER.debug(
|
|
||||||
"No vocal record found for device %s [%s]",
|
|
||||||
self.device.account_name,
|
|
||||||
self.device.serial_number,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if vocal_record.timestamp == self._last_seen_timestamp:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._last_seen_timestamp = vocal_record.timestamp
|
|
||||||
self._trigger_event(
|
|
||||||
EVENT_TYPE,
|
|
||||||
{
|
|
||||||
"intent": vocal_record.intent,
|
|
||||||
"voice_command": vocal_record.title,
|
|
||||||
"voice_reply": vocal_record.sub_title,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.async_write_ha_state()
|
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entity": {
|
"entity": {
|
||||||
"event": {
|
|
||||||
"voice_event": {
|
|
||||||
"default": "mdi:chat-processing"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"voc_index": {
|
"voc_index": {
|
||||||
"default": "mdi:molecule"
|
"default": "mdi:molecule"
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["aioamazondevices"],
|
"loggers": ["aioamazondevices"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["aioamazondevices==13.8.0"]
|
"requirements": ["aioamazondevices==13.7.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -58,18 +58,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"event": {
|
|
||||||
"voice_event": {
|
|
||||||
"name": "Voice event",
|
|
||||||
"state_attributes": {
|
|
||||||
"event_type": {
|
|
||||||
"state": {
|
|
||||||
"triggered": "Triggered"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notify": {
|
"notify": {
|
||||||
"announce": {
|
"announce": {
|
||||||
"name": "Announce"
|
"name": "Announce"
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_HOST
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import CONF_HOST, DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
"""Constants for the Altruist integration."""
|
"""Constants for the Altruist integration."""
|
||||||
|
|
||||||
DOMAIN = "altruist"
|
DOMAIN = "altruist"
|
||||||
|
|
||||||
|
# pylint: disable-next=home-assistant-duplicate-const
|
||||||
|
CONF_HOST = "host"
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import logging
|
|||||||
from altruistclient import AltruistClient, AltruistError
|
from altruistclient import AltruistClient, AltruistError
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import CONF_HOST
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
UPDATE_INTERVAL = timedelta(seconds=15)
|
UPDATE_INTERVAL = timedelta(seconds=15)
|
||||||
|
|||||||
@@ -230,13 +230,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
|
|||||||
|
|
||||||
if entry.version == 2 and entry.minor_version == 3:
|
if entry.version == 2 and entry.minor_version == 3:
|
||||||
# Remove Temperature parameter
|
# Remove Temperature parameter
|
||||||
temperature_key = "temperature"
|
CONF_TEMPERATURE = "temperature"
|
||||||
|
|
||||||
for subentry in entry.subentries.values():
|
for subentry in entry.subentries.values():
|
||||||
data = subentry.data.copy()
|
data = subentry.data.copy()
|
||||||
if temperature_key not in data:
|
if CONF_TEMPERATURE not in data:
|
||||||
continue
|
continue
|
||||||
data.pop(temperature_key, None)
|
data.pop(CONF_TEMPERATURE, None)
|
||||||
hass.config_entries.async_update_subentry(entry, subentry, data=data)
|
hass.config_entries.async_update_subentry(entry, subentry, data=data)
|
||||||
|
|
||||||
hass.config_entries.async_update_entry(entry, minor_version=4)
|
hass.config_entries.async_update_entry(entry, minor_version=4)
|
||||||
|
|||||||
@@ -7,3 +7,27 @@ CONNECTION_TIMEOUT: int = 10
|
|||||||
|
|
||||||
# Field name of last self test retrieved from apcupsd.
|
# Field name of last self test retrieved from apcupsd.
|
||||||
LAST_S_TEST: Final = "laststest"
|
LAST_S_TEST: Final = "laststest"
|
||||||
|
|
||||||
|
# Mapping of deprecated sensor keys (as reported by apcupsd,
|
||||||
|
# lower-cased) to their deprecation
|
||||||
|
# repair issue translation keys.
|
||||||
|
DEPRECATED_SENSORS: Final = {
|
||||||
|
"apc": "apc_deprecated",
|
||||||
|
"end apc": "date_deprecated",
|
||||||
|
"date": "date_deprecated",
|
||||||
|
"apcmodel": "available_via_device_info",
|
||||||
|
"model": "available_via_device_info",
|
||||||
|
"firmware": "available_via_device_info",
|
||||||
|
"version": "available_via_device_info",
|
||||||
|
"upsname": "available_via_device_info",
|
||||||
|
"serialno": "available_via_device_info",
|
||||||
|
}
|
||||||
|
|
||||||
|
AVAILABLE_VIA_DEVICE_ATTR: Final = {
|
||||||
|
"apcmodel": "model",
|
||||||
|
"model": "model",
|
||||||
|
"firmware": "hw_version",
|
||||||
|
"version": "sw_version",
|
||||||
|
"upsname": "name",
|
||||||
|
"serialno": "serial_number",
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Support for APCUPSd sensors."""
|
"""Support for APCUPSd sensors."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
import dateutil
|
import dateutil
|
||||||
|
|
||||||
|
from homeassistant.components.automation import automations_with_entity
|
||||||
|
from homeassistant.components.script import scripts_with_entity
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
SensorEntity,
|
SensorEntity,
|
||||||
@@ -23,9 +24,11 @@ from homeassistant.const import (
|
|||||||
UnitOfTime,
|
UnitOfTime,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
import homeassistant.helpers.issue_registry as ir
|
||||||
|
|
||||||
from .const import LAST_S_TEST
|
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST
|
||||||
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
|
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
|
||||||
from .entity import APCUPSdEntity
|
from .entity import APCUPSdEntity
|
||||||
|
|
||||||
@@ -33,20 +36,6 @@ PARALLEL_UPDATES = 0
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# List of useless sensors to ignore, since they are either provided in device
|
|
||||||
# information, or not useful at all
|
|
||||||
IGNORED_SENSORS: Final = {
|
|
||||||
"apc",
|
|
||||||
"end apc",
|
|
||||||
"date",
|
|
||||||
"apcmodel",
|
|
||||||
"model",
|
|
||||||
"firmware",
|
|
||||||
"version",
|
|
||||||
"upsname",
|
|
||||||
"serialno",
|
|
||||||
}
|
|
||||||
|
|
||||||
SENSORS: dict[str, SensorEntityDescription] = {
|
SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
"alarmdel": SensorEntityDescription(
|
"alarmdel": SensorEntityDescription(
|
||||||
key="alarmdel",
|
key="alarmdel",
|
||||||
@@ -60,6 +49,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
),
|
||||||
|
"apc": SensorEntityDescription(
|
||||||
|
key="apc",
|
||||||
|
translation_key="apc_status",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
|
"apcmodel": SensorEntityDescription(
|
||||||
|
key="apcmodel",
|
||||||
|
translation_key="apc_model",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
"badbatts": SensorEntityDescription(
|
"badbatts": SensorEntityDescription(
|
||||||
key="badbatts",
|
key="badbatts",
|
||||||
translation_key="bad_batteries",
|
translation_key="bad_batteries",
|
||||||
@@ -99,6 +100,12 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
),
|
),
|
||||||
|
"date": SensorEntityDescription(
|
||||||
|
key="date",
|
||||||
|
translation_key="date",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
"dipsw": SensorEntityDescription(
|
"dipsw": SensorEntityDescription(
|
||||||
key="dipsw",
|
key="dipsw",
|
||||||
translation_key="dip_switch_settings",
|
translation_key="dip_switch_settings",
|
||||||
@@ -125,11 +132,23 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
translation_key="wake_delay",
|
translation_key="wake_delay",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
"end apc": SensorEntityDescription(
|
||||||
|
key="end apc",
|
||||||
|
translation_key="date_and_time",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
"extbatts": SensorEntityDescription(
|
"extbatts": SensorEntityDescription(
|
||||||
key="extbatts",
|
key="extbatts",
|
||||||
translation_key="external_batteries",
|
translation_key="external_batteries",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
"firmware": SensorEntityDescription(
|
||||||
|
key="firmware",
|
||||||
|
translation_key="firmware_version",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
"hitrans": SensorEntityDescription(
|
"hitrans": SensorEntityDescription(
|
||||||
key="hitrans",
|
key="hitrans",
|
||||||
translation_key="transfer_high",
|
translation_key="transfer_high",
|
||||||
@@ -245,6 +264,12 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
translation_key="min_time",
|
translation_key="min_time",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
"model": SensorEntityDescription(
|
||||||
|
key="model",
|
||||||
|
translation_key="model",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
"nombattv": SensorEntityDescription(
|
"nombattv": SensorEntityDescription(
|
||||||
key="nombattv",
|
key="nombattv",
|
||||||
translation_key="battery_nominal_voltage",
|
translation_key="battery_nominal_voltage",
|
||||||
@@ -333,6 +358,12 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
"serialno": SensorEntityDescription(
|
||||||
|
key="serialno",
|
||||||
|
translation_key="serial_number",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
"starttime": SensorEntityDescription(
|
"starttime": SensorEntityDescription(
|
||||||
key="starttime",
|
key="starttime",
|
||||||
translation_key="startup_time",
|
translation_key="startup_time",
|
||||||
@@ -373,6 +404,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
translation_key="ups_mode",
|
translation_key="ups_mode",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
"upsname": SensorEntityDescription(
|
||||||
|
key="upsname",
|
||||||
|
translation_key="ups_name",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
|
"version": SensorEntityDescription(
|
||||||
|
key="version",
|
||||||
|
translation_key="version",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
"xoffbat": SensorEntityDescription(
|
"xoffbat": SensorEntityDescription(
|
||||||
key="xoffbat",
|
key="xoffbat",
|
||||||
translation_key="transfer_from_battery",
|
translation_key="transfer_from_battery",
|
||||||
@@ -438,10 +481,9 @@ async def async_setup_entry(
|
|||||||
# as unknown initially.
|
# as unknown initially.
|
||||||
#
|
#
|
||||||
# We also sort the resources to ensure the order of entities
|
# We also sort the resources to ensure the order of entities
|
||||||
# created is deterministic
|
# created is deterministic since "APCMODEL" and "MODEL"
|
||||||
|
# resources map to the same "Model" name.
|
||||||
for resource in sorted(available_resources | {LAST_S_TEST}):
|
for resource in sorted(available_resources | {LAST_S_TEST}):
|
||||||
if resource in IGNORED_SENSORS:
|
|
||||||
continue
|
|
||||||
if resource not in SENSORS:
|
if resource not in SENSORS:
|
||||||
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
||||||
continue
|
continue
|
||||||
@@ -519,3 +561,63 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
|||||||
self._attr_native_value, inferred_unit = infer_unit(data)
|
self._attr_native_value, inferred_unit = infer_unit(data)
|
||||||
if not self.native_unit_of_measurement:
|
if not self.native_unit_of_measurement:
|
||||||
self._attr_native_unit_of_measurement = inferred_unit
|
self._attr_native_unit_of_measurement = inferred_unit
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Handle when entity is added to Home Assistant.
|
||||||
|
|
||||||
|
If this is a deprecated sensor entity, create a repair issue to guide
|
||||||
|
the user to disable it.
|
||||||
|
"""
|
||||||
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
reason = DEPRECATED_SENSORS.get(self.entity_description.key)
|
||||||
|
if not reason:
|
||||||
|
return
|
||||||
|
|
||||||
|
automations = automations_with_entity(self.hass, self.entity_id)
|
||||||
|
scripts = scripts_with_entity(self.hass, self.entity_id)
|
||||||
|
if not automations and not scripts:
|
||||||
|
return
|
||||||
|
|
||||||
|
entity_registry = er.async_get(self.hass)
|
||||||
|
items = [
|
||||||
|
f"- [{entry.name or entry.original_name or entity_id}]"
|
||||||
|
f"(/config/{integration}/edit/"
|
||||||
|
f"{entry.unique_id or entity_id.split('.', 1)[-1]})"
|
||||||
|
for integration, entities in (
|
||||||
|
("automation", automations),
|
||||||
|
("script", scripts),
|
||||||
|
)
|
||||||
|
for entity_id in entities
|
||||||
|
if (entry := entity_registry.async_get(entity_id))
|
||||||
|
]
|
||||||
|
placeholders = {
|
||||||
|
"entity_name": str(self.name or self.entity_id),
|
||||||
|
"entity_id": self.entity_id,
|
||||||
|
"items": "\n".join(items),
|
||||||
|
}
|
||||||
|
if via_attr := AVAILABLE_VIA_DEVICE_ATTR.get(self.entity_description.key):
|
||||||
|
placeholders["available_via_device_attr"] = via_attr
|
||||||
|
if device_entry := self.device_entry:
|
||||||
|
placeholders["device_id"] = device_entry.id
|
||||||
|
|
||||||
|
ir.async_create_issue(
|
||||||
|
self.hass,
|
||||||
|
DOMAIN,
|
||||||
|
f"{reason}_{self.entity_id}",
|
||||||
|
breaks_in_ha_version="2026.6.0",
|
||||||
|
is_fixable=False,
|
||||||
|
severity=ir.IssueSeverity.WARNING,
|
||||||
|
translation_key=reason,
|
||||||
|
translation_placeholders=placeholders,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
|
"""Handle when entity will be removed from Home Assistant."""
|
||||||
|
await super().async_will_remove_from_hass()
|
||||||
|
|
||||||
|
if issue_key := DEPRECATED_SENSORS.get(self.entity_description.key):
|
||||||
|
ir.async_delete_issue(self.hass, DOMAIN, f"{issue_key}_{self.entity_id}")
|
||||||
|
|||||||
@@ -241,5 +241,19 @@
|
|||||||
"cannot_connect": {
|
"cannot_connect": {
|
||||||
"message": "Cannot connect to APC UPS Daemon."
|
"message": "Cannot connect to APC UPS Daemon."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"apc_deprecated": {
|
||||||
|
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because it exposes internal details of the APC UPS Daemon response.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use supported APC UPS entities instead. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||||
|
"title": "{entity_name} sensor is deprecated"
|
||||||
|
},
|
||||||
|
"available_via_device_info": {
|
||||||
|
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the same value is available from the device registry via `device_attr(\"{device_id}\", \"{available_via_device_attr}\")`.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use the `device_attr` helper instead of this sensor. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||||
|
"title": "{entity_name} sensor is deprecated"
|
||||||
|
},
|
||||||
|
"date_deprecated": {
|
||||||
|
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the timestamp is already available from other APC UPS sensors via their last updated time.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to reference any entity's `last_updated` attribute instead (for example, `states.binary_sensor.apcups_online_status.last_updated`). Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||||
|
"title": "{entity_name} sensor is deprecated"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ from pyatv.interface import (
|
|||||||
PushListener,
|
PushListener,
|
||||||
PushUpdater,
|
PushUpdater,
|
||||||
)
|
)
|
||||||
from yarl import URL
|
|
||||||
|
|
||||||
from homeassistant.components import media_source
|
from homeassistant.components import media_source
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
@@ -346,10 +345,7 @@ class AppleTvMediaPlayer(
|
|||||||
play_item = await media_source.async_resolve_media(
|
play_item = await media_source.async_resolve_media(
|
||||||
self.hass, media_id, self.entity_id
|
self.hass, media_id, self.entity_id
|
||||||
)
|
)
|
||||||
if play_item.path and self._is_feature_available(FeatureName.StreamFile):
|
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||||
media_id = str(play_item.path)
|
|
||||||
else:
|
|
||||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
|
||||||
media_type = MediaType.MUSIC
|
media_type = MediaType.MUSIC
|
||||||
|
|
||||||
if self._is_feature_available(FeatureName.StreamFile) and (
|
if self._is_feature_available(FeatureName.StreamFile) and (
|
||||||
@@ -357,16 +353,11 @@ class AppleTvMediaPlayer(
|
|||||||
):
|
):
|
||||||
_LOGGER.debug("Streaming %s via RAOP", media_id)
|
_LOGGER.debug("Streaming %s via RAOP", media_id)
|
||||||
await self.atv.stream.stream_file(media_id)
|
await self.atv.stream.stream_file(media_id)
|
||||||
elif self._is_feature_available(FeatureName.PlayUrl) and (
|
elif self._is_feature_available(FeatureName.PlayUrl):
|
||||||
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
|
|
||||||
):
|
|
||||||
_LOGGER.debug("Playing %s via AirPlay", media_id)
|
_LOGGER.debug("Playing %s via AirPlay", media_id)
|
||||||
await self.atv.stream.play_url(media_id)
|
await self.atv.stream.play_url(media_id)
|
||||||
else:
|
else:
|
||||||
_LOGGER.error(
|
_LOGGER.error("Media streaming is not possible with current configuration")
|
||||||
"Media streaming is not possible with current configuration for %s",
|
|
||||||
media_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_image_hash(self) -> str | None:
|
def media_image_hash(self) -> str | None:
|
||||||
|
|||||||
@@ -193,11 +193,7 @@ async def async_setup_entry(
|
|||||||
Aranet4BluetoothSensorEntity, async_add_entities
|
Aranet4BluetoothSensorEntity, async_add_entities
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
entry.async_on_unload(
|
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
|
||||||
entry.runtime_data.async_register_processor(
|
|
||||||
processor, AranetSensorEntityDescription
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Aranet4BluetoothSensorEntity(
|
class Aranet4BluetoothSensorEntity(
|
||||||
|
|||||||
@@ -49,20 +49,6 @@ SENSORS_TYPE_COUNT = "sensors_count"
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ENTITY_MIGRATION_ID = {
|
|
||||||
"sensor_connected_device": "Devices Connected",
|
|
||||||
"sensor_rx_bytes": "Download",
|
|
||||||
"sensor_tx_bytes": "Upload",
|
|
||||||
"sensor_rx_rates": "Download Speed",
|
|
||||||
"sensor_tx_rates": "Upload Speed",
|
|
||||||
"sensor_load_avg1": "Load Avg (1m)",
|
|
||||||
"sensor_load_avg5": "Load Avg (5m)",
|
|
||||||
"sensor_load_avg15": "Load Avg (15m)",
|
|
||||||
"2.4GHz": "2.4GHz Temperature",
|
|
||||||
"5.0GHz": "5GHz Temperature",
|
|
||||||
"CPU": "CPU Temperature",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class AsusWrtSensorDataHandler:
|
class AsusWrtSensorDataHandler:
|
||||||
"""Data handler for AsusWrt sensor."""
|
"""Data handler for AsusWrt sensor."""
|
||||||
@@ -201,6 +187,20 @@ class AsusWrtRouter:
|
|||||||
|
|
||||||
def _migrate_entities_unique_id(self) -> None:
|
def _migrate_entities_unique_id(self) -> None:
|
||||||
"""Migrate router entities to new unique id format."""
|
"""Migrate router entities to new unique id format."""
|
||||||
|
_ENTITY_MIGRATION_ID = {
|
||||||
|
"sensor_connected_device": "Devices Connected",
|
||||||
|
"sensor_rx_bytes": "Download",
|
||||||
|
"sensor_tx_bytes": "Upload",
|
||||||
|
"sensor_rx_rates": "Download Speed",
|
||||||
|
"sensor_tx_rates": "Upload Speed",
|
||||||
|
"sensor_load_avg1": "Load Avg (1m)",
|
||||||
|
"sensor_load_avg5": "Load Avg (5m)",
|
||||||
|
"sensor_load_avg15": "Load Avg (15m)",
|
||||||
|
"2.4GHz": "2.4GHz Temperature",
|
||||||
|
"5.0GHz": "5GHz Temperature",
|
||||||
|
"CPU": "CPU Temperature",
|
||||||
|
}
|
||||||
|
|
||||||
entity_reg = er.async_get(self.hass)
|
entity_reg = er.async_get(self.hass)
|
||||||
router_entries = er.async_entries_for_config_entry(
|
router_entries = er.async_entries_for_config_entry(
|
||||||
entity_reg, self._entry.entry_id
|
entity_reg, self._entry.entry_id
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.components import usb
|
from homeassistant.components import usb
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import ATTR_MODEL, ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
|
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_FIRMWARE,
|
ATTR_FIRMWARE,
|
||||||
|
ATTR_MODEL,
|
||||||
DEFAULT_ADDRESS,
|
DEFAULT_ADDRESS,
|
||||||
DEFAULT_INTEGRATION_TITLE,
|
DEFAULT_INTEGRATION_TITLE,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
|||||||
@@ -19,4 +19,8 @@ DEVICES = "devices"
|
|||||||
MANUFACTURER = "ABB"
|
MANUFACTURER = "ABB"
|
||||||
|
|
||||||
ATTR_DEVICE_NAME = "device_name"
|
ATTR_DEVICE_NAME = "device_name"
|
||||||
|
# pylint: disable-next=home-assistant-duplicate-const
|
||||||
|
ATTR_DEVICE_ID = "device_id"
|
||||||
|
# pylint: disable-next=home-assistant-duplicate-const
|
||||||
|
ATTR_MODEL = "model"
|
||||||
ATTR_FIRMWARE = "firmware"
|
ATTR_FIRMWARE = "firmware"
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
|
|||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_MODEL,
|
|
||||||
ATTR_SERIAL_NUMBER,
|
ATTR_SERIAL_NUMBER,
|
||||||
EntityCategory,
|
EntityCategory,
|
||||||
UnitOfElectricCurrent,
|
UnitOfElectricCurrent,
|
||||||
@@ -32,6 +31,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|||||||
from .const import (
|
from .const import (
|
||||||
ATTR_DEVICE_NAME,
|
ATTR_DEVICE_NAME,
|
||||||
ATTR_FIRMWARE,
|
ATTR_FIRMWARE,
|
||||||
|
ATTR_MODEL,
|
||||||
DEFAULT_DEVICE_NAME,
|
DEFAULT_DEVICE_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
MANUFACTURER,
|
MANUFACTURER,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Light platform for Avea."""
|
"""Light platform for Avea."""
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -20,7 +19,6 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
|||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
from homeassistant.exceptions import PlatformNotReady
|
from homeassistant.exceptions import PlatformNotReady
|
||||||
from homeassistant.helpers import issue_registry as ir
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import (
|
from homeassistant.helpers.entity_platform import (
|
||||||
AddConfigEntryEntitiesCallback,
|
AddConfigEntryEntitiesCallback,
|
||||||
AddEntitiesCallback,
|
AddEntitiesCallback,
|
||||||
@@ -29,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
|||||||
from homeassistant.util import color as color_util
|
from homeassistant.util import color as color_util
|
||||||
|
|
||||||
from . import AveaConfigEntry
|
from . import AveaConfigEntry
|
||||||
from .const import DOMAIN, INTEGRATION_TITLE, MODEL, UNKNOWN_NAME
|
from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
|
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
|
||||||
@@ -44,13 +42,6 @@ def _normalize_name(name: str | None) -> str | None:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
def _read_device_info_value(read: Callable[[], str | None]) -> str | None:
|
|
||||||
"""Read a device information value from an Avea bulb."""
|
|
||||||
with suppress(*UPDATE_EXCEPTIONS):
|
|
||||||
return _normalize_name(read())
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _ha_brightness_to_avea(brightness: int) -> int:
|
def _ha_brightness_to_avea(brightness: int) -> int:
|
||||||
"""Convert Home Assistant brightness to Avea brightness."""
|
"""Convert Home Assistant brightness to Avea brightness."""
|
||||||
return round((brightness / 255) * AVEA_MAX_BRIGHTNESS)
|
return round((brightness / 255) * AVEA_MAX_BRIGHTNESS)
|
||||||
@@ -105,8 +96,7 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Avea light platform."""
|
"""Set up the Avea light platform."""
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[AveaLight(entry.runtime_data, entry.data[CONF_ADDRESS])],
|
[AveaLight(entry.runtime_data, entry.title)], update_before_add=True
|
||||||
update_before_add=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -190,42 +180,14 @@ class AveaLight(LightEntity):
|
|||||||
"""Representation of an Avea."""
|
"""Representation of an Avea."""
|
||||||
|
|
||||||
_attr_color_mode = ColorMode.HS
|
_attr_color_mode = ColorMode.HS
|
||||||
_attr_has_entity_name = True
|
|
||||||
_attr_name = None
|
|
||||||
_attr_supported_color_modes = {ColorMode.HS}
|
_attr_supported_color_modes = {ColorMode.HS}
|
||||||
|
|
||||||
def __init__(self, light: avea.Bulb, address: str) -> None:
|
def __init__(self, light: avea.Bulb, entry_title: str) -> None:
|
||||||
"""Initialize an AveaLight."""
|
"""Initialize an AveaLight."""
|
||||||
self._light = light
|
self._light = light
|
||||||
self._attr_unique_id = address
|
self._attr_name = entry_title
|
||||||
self._attr_brightness = light.brightness
|
self._attr_brightness = light.brightness
|
||||||
self._last_brightness = 255
|
self._last_brightness = 255
|
||||||
self._device_info_updated = False
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
connections={(CONNECTION_BLUETOOTH, address)},
|
|
||||||
model=MODEL,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _update_device_info(self) -> None:
|
|
||||||
"""Fetch device information from the Avea bulb."""
|
|
||||||
device_info = self._attr_device_info
|
|
||||||
assert device_info is not None
|
|
||||||
|
|
||||||
manufacturer = _read_device_info_value(self._light.get_manufacturer_name)
|
|
||||||
hardware_revision = _read_device_info_value(self._light.get_hardware_revision)
|
|
||||||
firmware_version = _read_device_info_value(self._light.get_fw_version)
|
|
||||||
serial_number = _read_device_info_value(self._light.get_serial_number)
|
|
||||||
|
|
||||||
if manufacturer:
|
|
||||||
device_info["manufacturer"] = manufacturer
|
|
||||||
if hardware_revision:
|
|
||||||
device_info["hw_version"] = hardware_revision
|
|
||||||
if firmware_version:
|
|
||||||
device_info["sw_version"] = firmware_version
|
|
||||||
if serial_number:
|
|
||||||
device_info["serial_number"] = serial_number
|
|
||||||
|
|
||||||
self._device_info_updated = True
|
|
||||||
|
|
||||||
def turn_on(self, **kwargs: Any) -> None:
|
def turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Instruct the light to turn on."""
|
"""Instruct the light to turn on."""
|
||||||
@@ -252,8 +214,6 @@ class AveaLight(LightEntity):
|
|||||||
connected = self._light.connect()
|
connected = self._light.connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._device_info_updated:
|
|
||||||
self._update_device_info()
|
|
||||||
brightness = self._light.get_brightness()
|
brightness = self._light.get_brightness()
|
||||||
rgb_color = self._light.get_rgb()
|
rgb_color = self._light.get_rgb()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ from homeassistant.components.backup import (
|
|||||||
OnProgressCallback,
|
OnProgressCallback,
|
||||||
suggested_filename,
|
suggested_filename,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_PREFIX
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
from . import S3ConfigEntry
|
from . import S3ConfigEntry
|
||||||
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||||
from .helpers import async_list_backups_from_s3
|
from .helpers import async_list_backups_from_s3
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from botocore.exceptions import ClientError, ConnectionError, ParamValidationErr
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_PREFIX
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.selector import (
|
from homeassistant.helpers.selector import (
|
||||||
TextSelector,
|
TextSelector,
|
||||||
@@ -21,6 +20,7 @@ from .const import (
|
|||||||
CONF_ACCESS_KEY_ID,
|
CONF_ACCESS_KEY_ID,
|
||||||
CONF_BUCKET,
|
CONF_BUCKET,
|
||||||
CONF_ENDPOINT_URL,
|
CONF_ENDPOINT_URL,
|
||||||
|
CONF_PREFIX,
|
||||||
CONF_SECRET_ACCESS_KEY,
|
CONF_SECRET_ACCESS_KEY,
|
||||||
DEFAULT_ENDPOINT_URL,
|
DEFAULT_ENDPOINT_URL,
|
||||||
DESCRIPTION_AWS_S3_DOCS_URL,
|
DESCRIPTION_AWS_S3_DOCS_URL,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ CONF_ACCESS_KEY_ID = "access_key_id"
|
|||||||
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||||
CONF_ENDPOINT_URL = "endpoint_url"
|
CONF_ENDPOINT_URL = "endpoint_url"
|
||||||
CONF_BUCKET = "bucket"
|
CONF_BUCKET = "bucket"
|
||||||
|
# pylint: disable-next=home-assistant-duplicate-const
|
||||||
|
CONF_PREFIX = "prefix"
|
||||||
|
|
||||||
AWS_DOMAIN = "amazonaws.com"
|
AWS_DOMAIN = "amazonaws.com"
|
||||||
DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/"
|
DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/"
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ from aiobotocore.client import AioBaseClient as S3Client
|
|||||||
from botocore.exceptions import BotoCoreError
|
from botocore.exceptions import BotoCoreError
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_PREFIX
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import CONF_BUCKET, DOMAIN
|
from .const import CONF_BUCKET, CONF_PREFIX, DOMAIN
|
||||||
from .helpers import async_list_backups_from_s3
|
from .helpers import async_list_backups_from_s3
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(hours=6)
|
SCAN_INTERVAL = timedelta(hours=6)
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ from typing import Any
|
|||||||
|
|
||||||
from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER
|
from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER
|
||||||
from homeassistant.components.diagnostics import async_redact_data
|
from homeassistant.components.diagnostics import async_redact_data
|
||||||
from homeassistant.const import CONF_PREFIX
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import CONF_ACCESS_KEY_ID, CONF_BUCKET, CONF_SECRET_ACCESS_KEY, DOMAIN
|
from .const import (
|
||||||
|
CONF_ACCESS_KEY_ID,
|
||||||
|
CONF_BUCKET,
|
||||||
|
CONF_PREFIX,
|
||||||
|
CONF_SECRET_ACCESS_KEY,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
from .coordinator import S3ConfigEntry
|
from .coordinator import S3ConfigEntry
|
||||||
from .helpers import async_list_backups_from_s3
|
from .helpers import async_list_backups_from_s3
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import (
|
||||||
|
SOURCE_IGNORE,
|
||||||
SOURCE_REAUTH,
|
SOURCE_REAUTH,
|
||||||
SOURCE_RECONFIGURE,
|
SOURCE_RECONFIGURE,
|
||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
@@ -49,9 +50,6 @@ from .const import (
|
|||||||
from .errors import AuthenticationRequired, CannotConnect
|
from .errors import AuthenticationRequired, CannotConnect
|
||||||
from .hub import AxisHub, get_axis_api
|
from .hub import AxisHub, get_axis_api
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import axis
|
|
||||||
|
|
||||||
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f", "e8:27:25"}
|
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f", "e8:27:25"}
|
||||||
DEFAULT_PORT = 443
|
DEFAULT_PORT = 443
|
||||||
DEFAULT_PROTOCOL = "https"
|
DEFAULT_PROTOCOL = "https"
|
||||||
@@ -96,8 +94,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if (serial := self._get_serial_number(api)) is None:
|
serial = api.vapix.serial_number
|
||||||
return self.async_abort(reason="no_serial_number")
|
|
||||||
config = {
|
config = {
|
||||||
CONF_PROTOCOL: user_input[CONF_PROTOCOL],
|
CONF_PROTOCOL: user_input[CONF_PROTOCOL],
|
||||||
CONF_HOST: user_input[CONF_HOST],
|
CONF_HOST: user_input[CONF_HOST],
|
||||||
@@ -142,15 +139,25 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
async def _create_entry(self, serial: str) -> ConfigFlowResult:
|
async def _create_entry(self, serial: str) -> ConfigFlowResult:
|
||||||
"""Create entry for device.
|
"""Create entry for device.
|
||||||
|
|
||||||
Use the discovered device name when available.
|
Generate a name to be used as a prefix for device entities.
|
||||||
"""
|
"""
|
||||||
if (title_placeholders := self.context.get("title_placeholders")) is not None:
|
model = self.config[CONF_MODEL]
|
||||||
name = title_placeholders[CONF_NAME]
|
same_model = [
|
||||||
else:
|
entry.data[CONF_NAME]
|
||||||
name = f"{self.config[CONF_MODEL]} - {serial}"
|
for entry in self.hass.config_entries.async_entries(DOMAIN)
|
||||||
|
if entry.source != SOURCE_IGNORE and entry.data[CONF_MODEL] == model
|
||||||
|
]
|
||||||
|
|
||||||
|
name = model
|
||||||
|
for idx in range(len(same_model) + 1):
|
||||||
|
name = f"{model} {idx}"
|
||||||
|
if name not in same_model:
|
||||||
|
break
|
||||||
|
|
||||||
self.config[CONF_NAME] = name
|
self.config[CONF_NAME] = name
|
||||||
|
|
||||||
return self.async_create_entry(title=name, data=self.config)
|
title = f"{model} - {serial}"
|
||||||
|
return self.async_create_entry(title=title, data=self.config)
|
||||||
|
|
||||||
async def async_step_reconfigure(
|
async def async_step_reconfigure(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
@@ -262,19 +269,6 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return await self.async_step_user()
|
return await self.async_step_user()
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_serial_number(api: axis.AxisDevice) -> str | None:
|
|
||||||
"""Retrieve the device serial number from the Axis API.
|
|
||||||
|
|
||||||
Tries basic_device_info first, then property_handler. Returns None if not found.
|
|
||||||
"""
|
|
||||||
vapix = api.vapix
|
|
||||||
if vapix.basic_device_info.initialized:
|
|
||||||
return vapix.basic_device_info["0"].serial_number
|
|
||||||
if vapix.params.property_handler.initialized:
|
|
||||||
return vapix.params.property_handler["0"].system_serial_number
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class AxisOptionsFlowHandler(OptionsFlow):
|
class AxisOptionsFlowHandler(OptionsFlow):
|
||||||
"""Handle Axis device options."""
|
"""Handle Axis device options."""
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import axis
|
import axis
|
||||||
from axis.errors import Unauthorized
|
from axis.errors import Unauthorized
|
||||||
from axis.models.mqtt import ClientState, mqtt_json_to_event
|
from axis.interfaces.mqtt import mqtt_json_to_event
|
||||||
|
from axis.models.mqtt import ClientState
|
||||||
from axis.stream_manager import Signal, State
|
from axis.stream_manager import Signal, State
|
||||||
|
|
||||||
from homeassistant.components import mqtt
|
from homeassistant.components import mqtt
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["axis"],
|
"loggers": ["axis"],
|
||||||
"requirements": ["axis==72"],
|
"requirements": ["axis==71"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"manufacturer": "AXIS"
|
"manufacturer": "AXIS"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
"link_local_address": "Link local addresses are not supported",
|
"link_local_address": "Link local addresses are not supported",
|
||||||
"no_serial_number": "Could not retrieve a serial number from the device. Please check device connectivity and try again.",
|
|
||||||
"not_axis_device": "Discovered device not an Axis device",
|
"not_axis_device": "Discovered device not an Axis device",
|
||||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ PLATFORMS = [
|
|||||||
Platform.LIGHT,
|
Platform.LIGHT,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
Platform.UPDATE,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import blebox_uniapi.cover
|
import blebox_uniapi.cover
|
||||||
from blebox_uniapi.cover import BleboxCoverState, UnifiedCoverType
|
from blebox_uniapi.cover import BleboxCoverState
|
||||||
|
|
||||||
from homeassistant.components.cover import (
|
from homeassistant.components.cover import (
|
||||||
ATTR_POSITION,
|
ATTR_POSITION,
|
||||||
@@ -25,19 +25,6 @@ BLEBOX_TO_COVER_DEVICE_CLASSES = {
|
|||||||
"shutter": CoverDeviceClass.SHUTTER,
|
"shutter": CoverDeviceClass.SHUTTER,
|
||||||
}
|
}
|
||||||
|
|
||||||
UNIFIED_COVER_TYPE_TO_DEVICE_CLASS = {
|
|
||||||
UnifiedCoverType.AWNING: CoverDeviceClass.AWNING,
|
|
||||||
UnifiedCoverType.BLIND: CoverDeviceClass.BLIND,
|
|
||||||
UnifiedCoverType.CURTAIN: CoverDeviceClass.CURTAIN,
|
|
||||||
UnifiedCoverType.DAMPER: CoverDeviceClass.DAMPER,
|
|
||||||
UnifiedCoverType.DOOR: CoverDeviceClass.DOOR,
|
|
||||||
UnifiedCoverType.GARAGE: CoverDeviceClass.GARAGE,
|
|
||||||
UnifiedCoverType.GATE: CoverDeviceClass.GATE,
|
|
||||||
UnifiedCoverType.SHADE: CoverDeviceClass.SHADE,
|
|
||||||
UnifiedCoverType.SHUTTER: CoverDeviceClass.SHUTTER,
|
|
||||||
UnifiedCoverType.WINDOW: CoverDeviceClass.WINDOW,
|
|
||||||
}
|
|
||||||
|
|
||||||
BLEBOX_TO_HASS_COVER_STATES = {
|
BLEBOX_TO_HASS_COVER_STATES = {
|
||||||
None: None,
|
None: None,
|
||||||
# all blebox covers
|
# all blebox covers
|
||||||
@@ -72,6 +59,7 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
|||||||
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
|
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
|
||||||
"""Initialize a BleBox cover feature."""
|
"""Initialize a BleBox cover feature."""
|
||||||
super().__init__(feature)
|
super().__init__(feature)
|
||||||
|
self._attr_device_class = BLEBOX_TO_COVER_DEVICE_CLASSES[feature.device_class]
|
||||||
self._attr_supported_features = (
|
self._attr_supported_features = (
|
||||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||||
)
|
)
|
||||||
@@ -88,21 +76,6 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
|||||||
| CoverEntityFeature.CLOSE_TILT
|
| CoverEntityFeature.CLOSE_TILT
|
||||||
)
|
)
|
||||||
|
|
||||||
if feature.tilt_only:
|
|
||||||
self._attr_supported_features &= ~(
|
|
||||||
CoverEntityFeature.OPEN
|
|
||||||
| CoverEntityFeature.CLOSE
|
|
||||||
| CoverEntityFeature.SET_POSITION
|
|
||||||
| CoverEntityFeature.STOP
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def device_class(self) -> CoverDeviceClass | None:
|
|
||||||
"""Return the device class based on cover type when available."""
|
|
||||||
if (cover_type := self._feature.cover_type) is not None:
|
|
||||||
return UNIFIED_COVER_TYPE_TO_DEVICE_CLASS[cover_type]
|
|
||||||
return BLEBOX_TO_COVER_DEVICE_CLASSES[self._feature.device_class]
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_cover_position(self) -> int | None:
|
def current_cover_position(self) -> int | None:
|
||||||
"""Return the current cover position."""
|
"""Return the current cover position."""
|
||||||
@@ -145,8 +118,7 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
|||||||
|
|
||||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||||
"""Fully open the cover tilt."""
|
"""Fully open the cover tilt."""
|
||||||
position = 50 if self._feature.is_tilt_180 else 0
|
await self._feature.async_set_tilt_position(0)
|
||||||
await self._feature.async_set_tilt_position(position)
|
|
||||||
|
|
||||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||||
"""Fully close the cover tilt."""
|
"""Fully close the cover tilt."""
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["blebox_uniapi"],
|
"loggers": ["blebox_uniapi"],
|
||||||
"requirements": ["blebox-uniapi==2.5.4"],
|
"requirements": ["blebox-uniapi==2.5.3"],
|
||||||
"zeroconf": ["_bbxsrv._tcp.local."]
|
"zeroconf": ["_bbxsrv._tcp.local."]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
"""BleBox update entities implementation."""
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Any, Final
|
|
||||||
|
|
||||||
from blebox_uniapi.error import ConnectionError as BleBoxConnectionError, Error
|
|
||||||
import blebox_uniapi.update
|
|
||||||
|
|
||||||
from homeassistant.components.update import (
|
|
||||||
UpdateDeviceClass,
|
|
||||||
UpdateEntity,
|
|
||||||
UpdateEntityFeature,
|
|
||||||
)
|
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers import device_registry as dr
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
from homeassistant.helpers.event import async_call_later
|
|
||||||
|
|
||||||
from . import BleBoxConfigEntry
|
|
||||||
from .entity import BleBoxEntity
|
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(hours=1)
|
|
||||||
|
|
||||||
|
|
||||||
_POLL_INTERVAL_SECONDS: Final = 10
|
|
||||||
_MAX_POLL_ATTEMPTS: Final = 30
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: BleBoxConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up a BleBox update entry."""
|
|
||||||
entities = [
|
|
||||||
BleBoxUpdateEntity(feature)
|
|
||||||
for feature in config_entry.runtime_data.features.get("updates", [])
|
|
||||||
]
|
|
||||||
async_add_entities(entities, True)
|
|
||||||
|
|
||||||
|
|
||||||
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
|
|
||||||
"""Representation of BleBox updates."""
|
|
||||||
|
|
||||||
_attr_device_class = UpdateDeviceClass.FIRMWARE
|
|
||||||
_attr_supported_features = (
|
|
||||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
|
|
||||||
"""Initialize the update entity."""
|
|
||||||
super().__init__(feature)
|
|
||||||
self._in_progress_old_version: str | None = None
|
|
||||||
self._poll_cancel: CALLBACK_TYPE | None = None
|
|
||||||
self._poll_attempts: int = 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def in_progress(self) -> bool:
|
|
||||||
"""Return True while the device hasn't yet rebooted to the new firmware."""
|
|
||||||
return (
|
|
||||||
self._in_progress_old_version is not None
|
|
||||||
and self._in_progress_old_version == self._feature.installed_version
|
|
||||||
)
|
|
||||||
|
|
||||||
def _sync_sw_version(self) -> None:
|
|
||||||
"""Sync installed firmware version to the device registry."""
|
|
||||||
if self.device_entry:
|
|
||||||
dr.async_get(self.hass).async_update_device(
|
|
||||||
self.device_entry.id,
|
|
||||||
sw_version=self._feature.installed_version,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
|
||||||
"""Update state and refresh sw_version in device registry."""
|
|
||||||
try:
|
|
||||||
await self._feature.async_update()
|
|
||||||
except Error as ex:
|
|
||||||
raise HomeAssistantError(ex) from ex
|
|
||||||
self._sync_sw_version()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def installed_version(self) -> str | None:
|
|
||||||
"""Version installed and in use."""
|
|
||||||
return self._feature.installed_version
|
|
||||||
|
|
||||||
@property
|
|
||||||
def latest_version(self) -> str | None:
|
|
||||||
"""Latest version available for install."""
|
|
||||||
return self._feature.latest_version
|
|
||||||
|
|
||||||
def _cancel_poll(self) -> None:
|
|
||||||
if self._poll_cancel is not None:
|
|
||||||
self._poll_cancel()
|
|
||||||
self._poll_cancel = None
|
|
||||||
|
|
||||||
def _reset_progress(self) -> None:
|
|
||||||
self._in_progress_old_version = None
|
|
||||||
self._poll_attempts = 0
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
async def async_install(
|
|
||||||
self, version: str | None, backup: bool, **kwargs: Any
|
|
||||||
) -> None:
|
|
||||||
"""Install an update."""
|
|
||||||
self._cancel_poll()
|
|
||||||
self._in_progress_old_version = self._feature.installed_version
|
|
||||||
self._poll_attempts = 0
|
|
||||||
self.async_write_ha_state()
|
|
||||||
try:
|
|
||||||
await self._feature.async_install()
|
|
||||||
except Error as ex:
|
|
||||||
self._reset_progress()
|
|
||||||
raise HomeAssistantError(ex) from ex
|
|
||||||
self._poll_cancel = async_call_later(
|
|
||||||
self.hass, _POLL_INTERVAL_SECONDS, self._poll_until_updated
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
|
||||||
"""Cancel any pending poll timer when the entity is removed."""
|
|
||||||
self._cancel_poll()
|
|
||||||
|
|
||||||
async def _poll_until_updated(self, _now: Any) -> None:
|
|
||||||
"""Poll device until the installed version changes after OTA reboot."""
|
|
||||||
self._poll_cancel = None
|
|
||||||
self._poll_attempts += 1
|
|
||||||
try:
|
|
||||||
await self._feature.async_update()
|
|
||||||
except BleBoxConnectionError:
|
|
||||||
pass
|
|
||||||
except Error:
|
|
||||||
self._reset_progress()
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
self._sync_sw_version()
|
|
||||||
if self.in_progress and self._poll_attempts < _MAX_POLL_ATTEMPTS:
|
|
||||||
self._poll_cancel = async_call_later(
|
|
||||||
self.hass, _POLL_INTERVAL_SECONDS, self._poll_until_updated
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._reset_progress()
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "blue_current",
|
"domain": "blue_current",
|
||||||
"name": "Blue Current",
|
"name": "Blue Current",
|
||||||
"codeowners": ["@gleeuwen", "@jtodorova23"],
|
"codeowners": ["@gleeuwen", "@NickKoepr", "@jtodorova23"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/blue_current",
|
"documentation": "https://www.home-assistant.io/integrations/blue_current",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
|||||||
@@ -124,9 +124,7 @@ async def async_setup_entry(
|
|||||||
BlueMaestroBluetoothSensorEntity, async_add_entities
|
BlueMaestroBluetoothSensorEntity, async_add_entities
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
entry.async_on_unload(
|
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BlueMaestroBluetoothSensorEntity(
|
class BlueMaestroBluetoothSensorEntity(
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from bluetooth_adapters import (
|
|||||||
ADAPTER_CONNECTION_SLOTS,
|
ADAPTER_CONNECTION_SLOTS,
|
||||||
ADAPTER_HW_VERSION,
|
ADAPTER_HW_VERSION,
|
||||||
ADAPTER_MANUFACTURER,
|
ADAPTER_MANUFACTURER,
|
||||||
ADAPTER_PASSIVE_SCAN,
|
|
||||||
ADAPTER_SW_VERSION,
|
ADAPTER_SW_VERSION,
|
||||||
DEFAULT_ADDRESS,
|
DEFAULT_ADDRESS,
|
||||||
DEFAULT_CONNECTION_SLOTS,
|
DEFAULT_CONNECTION_SLOTS,
|
||||||
@@ -70,7 +69,6 @@ from .api import (
|
|||||||
async_register_callback,
|
async_register_callback,
|
||||||
async_register_scanner,
|
async_register_scanner,
|
||||||
async_remove_scanner,
|
async_remove_scanner,
|
||||||
async_request_active_scan,
|
|
||||||
async_scanner_by_source,
|
async_scanner_by_source,
|
||||||
async_scanner_count,
|
async_scanner_count,
|
||||||
async_scanner_devices_by_address,
|
async_scanner_devices_by_address,
|
||||||
@@ -81,6 +79,7 @@ from .const import (
|
|||||||
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
|
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
|
||||||
CONF_ADAPTER,
|
CONF_ADAPTER,
|
||||||
CONF_DETAILS,
|
CONF_DETAILS,
|
||||||
|
CONF_PASSIVE,
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||||
CONF_SOURCE_DEVICE_ID,
|
CONF_SOURCE_DEVICE_ID,
|
||||||
CONF_SOURCE_DOMAIN,
|
CONF_SOURCE_DOMAIN,
|
||||||
@@ -94,7 +93,7 @@ from .manager import HomeAssistantBluetoothManager
|
|||||||
from .match import BluetoothCallbackMatcher, IntegrationMatcher
|
from .match import BluetoothCallbackMatcher, IntegrationMatcher
|
||||||
from .models import BluetoothCallback, BluetoothChange
|
from .models import BluetoothCallback, BluetoothChange
|
||||||
from .storage import BluetoothStorage
|
from .storage import BluetoothStorage
|
||||||
from .util import adapter_title, resolve_scanning_mode
|
from .util import adapter_title
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
@@ -129,7 +128,6 @@ __all__ = [
|
|||||||
"async_register_callback",
|
"async_register_callback",
|
||||||
"async_register_scanner",
|
"async_register_scanner",
|
||||||
"async_remove_scanner",
|
"async_remove_scanner",
|
||||||
"async_request_active_scan",
|
|
||||||
"async_scanner_by_source",
|
"async_scanner_by_source",
|
||||||
"async_scanner_count",
|
"async_scanner_count",
|
||||||
"async_scanner_devices_by_address",
|
"async_scanner_devices_by_address",
|
||||||
@@ -389,15 +387,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
raise ConfigEntryNotReady(
|
raise ConfigEntryNotReady(
|
||||||
f"Bluetooth adapter {adapter} with address {address} not found"
|
f"Bluetooth adapter {adapter} with address {address} not found"
|
||||||
)
|
)
|
||||||
|
passive = entry.options.get(CONF_PASSIVE)
|
||||||
adapters = await manager.async_get_bluetooth_adapters()
|
adapters = await manager.async_get_bluetooth_adapters()
|
||||||
details = adapters[adapter]
|
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
|
||||||
mode = resolve_scanning_mode(entry.options)
|
|
||||||
# AUTO needs passive scanning support to flip on demand; without it
|
|
||||||
# the scanner would start passive on hardware that can't do passive.
|
|
||||||
if mode is BluetoothScanningMode.AUTO and not details.get(ADAPTER_PASSIVE_SCAN):
|
|
||||||
mode = BluetoothScanningMode.ACTIVE
|
|
||||||
scanner = HaScanner(mode, adapter, address)
|
scanner = HaScanner(mode, adapter, address)
|
||||||
scanner.async_setup()
|
scanner.async_setup()
|
||||||
|
details = adapters[adapter]
|
||||||
if entry.title == address:
|
if entry.title == address:
|
||||||
hass.config_entries.async_update_entry(
|
hass.config_entries.async_update_entry(
|
||||||
entry, title=adapter_title(adapter, details)
|
entry, title=adapter_title(adapter, details)
|
||||||
|
|||||||
@@ -68,20 +68,9 @@ class ActiveBluetoothProcessorCoordinator[_DataT](
|
|||||||
| None = None,
|
| None = None,
|
||||||
poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None,
|
poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None,
|
||||||
connectable: bool = True,
|
connectable: bool = True,
|
||||||
scan_interval: float | None = None,
|
|
||||||
scan_duration: float | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the processor."""
|
"""Initialize the processor."""
|
||||||
super().__init__(
|
super().__init__(hass, logger, address, mode, update_method, connectable)
|
||||||
hass,
|
|
||||||
logger,
|
|
||||||
address,
|
|
||||||
mode,
|
|
||||||
update_method,
|
|
||||||
connectable,
|
|
||||||
scan_interval,
|
|
||||||
scan_duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._needs_poll_method = needs_poll_method
|
self._needs_poll_method = needs_poll_method
|
||||||
self._poll_method = poll_method
|
self._poll_method = poll_method
|
||||||
|
|||||||
@@ -130,26 +130,17 @@ def async_register_callback(
|
|||||||
callback: BluetoothCallback,
|
callback: BluetoothCallback,
|
||||||
match_dict: BluetoothCallbackMatcher | None,
|
match_dict: BluetoothCallbackMatcher | None,
|
||||||
mode: BluetoothScanningMode,
|
mode: BluetoothScanningMode,
|
||||||
*,
|
|
||||||
scan_interval: float | None = None,
|
|
||||||
scan_duration: float | None = None,
|
|
||||||
) -> Callable[[], None]:
|
) -> Callable[[], None]:
|
||||||
"""Register to receive a callback on bluetooth change.
|
"""Register to receive a callback on bluetooth change.
|
||||||
|
|
||||||
When ``mode`` is not PASSIVE and ``match_dict["address"]`` is set,
|
mode is currently not used as we only support active scanning.
|
||||||
the address is registered with habluetooth's active-scan scheduler
|
Passive scanning will be available in the future. The flag
|
||||||
so AUTO-mode scanners flip ACTIVE on demand for that device.
|
is required to be present to avoid a future breaking change
|
||||||
``scan_interval`` / ``scan_duration`` default to habluetooth's
|
when we support passive scanning.
|
||||||
DEFAULT_ACTIVE_SCAN_* (5 minutes / 10 seconds) when not provided;
|
|
||||||
integrations that need a different cadence can pass explicit
|
|
||||||
values. Without an address in the matcher the active-scan request
|
|
||||||
is skipped; the callback itself still fires normally.
|
|
||||||
|
|
||||||
Returns a callback that can be used to cancel the registration.
|
Returns a callback that can be used to cancel the registration.
|
||||||
"""
|
"""
|
||||||
return _get_manager(hass).async_register_callback(
|
return _get_manager(hass).async_register_callback(callback, match_dict)
|
||||||
callback, match_dict, mode, scan_interval, scan_duration
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_process_advertisements(
|
async def async_process_advertisements(
|
||||||
@@ -170,7 +161,7 @@ async def async_process_advertisements(
|
|||||||
done.set_result(service_info)
|
done.set_result(service_info)
|
||||||
|
|
||||||
unload = _get_manager(hass).async_register_callback(
|
unload = _get_manager(hass).async_register_callback(
|
||||||
_async_discovered_device, match_dict, mode, scan_duration=timeout
|
_async_discovered_device, match_dict
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -284,19 +275,3 @@ def async_set_fallback_availability_interval(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Override the fallback availability timeout for a MAC address."""
|
"""Override the fallback availability timeout for a MAC address."""
|
||||||
_get_manager(hass).async_set_fallback_availability_interval(address, interval)
|
_get_manager(hass).async_set_fallback_availability_interval(address, interval)
|
||||||
|
|
||||||
|
|
||||||
async def async_request_active_scan(
|
|
||||||
hass: HomeAssistant, duration: float | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Run an on-demand active sweep across every AUTO scanner.
|
|
||||||
|
|
||||||
Intended for config-flow discovery and other one-shot probes that
|
|
||||||
need fresh advertisements without waiting for the periodic
|
|
||||||
rediscovery cadence. Awaits ``duration`` seconds so the caller can
|
|
||||||
then read newly discovered advertisements. Defaults to habluetooth's
|
|
||||||
on-demand sweep duration when ``duration`` is not provided; the
|
|
||||||
scheduler clamps the value to its allowed range. Concurrent callers
|
|
||||||
dedupe to a single bus-wide window.
|
|
||||||
"""
|
|
||||||
await _get_manager(hass).async_request_active_scan(duration)
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from bluetooth_adapters import (
|
|||||||
adapter_model,
|
adapter_model,
|
||||||
get_adapters,
|
get_adapters,
|
||||||
)
|
)
|
||||||
from habluetooth import BluetoothScanningMode, get_manager
|
from habluetooth import get_manager
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components import onboarding
|
from homeassistant.components import onboarding
|
||||||
@@ -22,64 +22,33 @@ from homeassistant.config_entries import (
|
|||||||
ConfigFlowResult,
|
ConfigFlowResult,
|
||||||
OptionsFlow,
|
OptionsFlow,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_SOURCE
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers.schema_config_entry_flow import (
|
from homeassistant.helpers.schema_config_entry_flow import (
|
||||||
SchemaCommonFlowHandler,
|
|
||||||
SchemaFlowFormStep,
|
SchemaFlowFormStep,
|
||||||
SchemaOptionsFlowHandler,
|
SchemaOptionsFlowHandler,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.selector import (
|
|
||||||
SelectSelector,
|
|
||||||
SelectSelectorConfig,
|
|
||||||
SelectSelectorMode,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.typing import DiscoveryInfoType
|
from homeassistant.helpers.typing import DiscoveryInfoType
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADAPTER,
|
CONF_ADAPTER,
|
||||||
CONF_DETAILS,
|
CONF_DETAILS,
|
||||||
CONF_MODE,
|
|
||||||
CONF_PASSIVE,
|
CONF_PASSIVE,
|
||||||
|
CONF_SOURCE,
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||||
CONF_SOURCE_DEVICE_ID,
|
CONF_SOURCE_DEVICE_ID,
|
||||||
CONF_SOURCE_DOMAIN,
|
CONF_SOURCE_DOMAIN,
|
||||||
CONF_SOURCE_MODEL,
|
CONF_SOURCE_MODEL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
from .util import adapter_title, resolve_scanning_mode
|
from .util import adapter_title
|
||||||
|
|
||||||
_MODE_SELECTOR = SelectSelector(
|
OPTIONS_SCHEMA = vol.Schema(
|
||||||
SelectSelectorConfig(
|
{
|
||||||
options=[
|
vol.Required(CONF_PASSIVE, default=False): bool,
|
||||||
BluetoothScanningMode.AUTO.value,
|
}
|
||||||
BluetoothScanningMode.ACTIVE.value,
|
|
||||||
BluetoothScanningMode.PASSIVE.value,
|
|
||||||
],
|
|
||||||
translation_key="mode",
|
|
||||||
mode=SelectSelectorMode.DROPDOWN,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
|
|
||||||
"""Build the options schema with the saved mode as the default."""
|
|
||||||
current = resolve_scanning_mode(handler.options).value
|
|
||||||
return vol.Schema({vol.Required(CONF_MODE, default=current): _MODE_SELECTOR})
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_options(
|
|
||||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Mirror CONF_MODE into the legacy CONF_PASSIVE for downgrade safety."""
|
|
||||||
user_input[CONF_PASSIVE] = (
|
|
||||||
user_input[CONF_MODE] == BluetoothScanningMode.PASSIVE.value
|
|
||||||
)
|
|
||||||
return user_input
|
|
||||||
|
|
||||||
|
|
||||||
OPTIONS_FLOW = {
|
OPTIONS_FLOW = {
|
||||||
"init": SchemaFlowFormStep(_options_schema, validate_user_input=_validate_options),
|
"init": SchemaFlowFormStep(OPTIONS_SCHEMA),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,21 +7,17 @@ from habluetooth import ( # noqa: F401
|
|||||||
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
|
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
|
||||||
SCANNER_WATCHDOG_INTERVAL,
|
SCANNER_WATCHDOG_INTERVAL,
|
||||||
SCANNER_WATCHDOG_TIMEOUT,
|
SCANNER_WATCHDOG_TIMEOUT,
|
||||||
BluetoothScanningMode,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant.const import CONF_MODE # noqa: F401
|
|
||||||
|
|
||||||
DOMAIN = "bluetooth"
|
DOMAIN = "bluetooth"
|
||||||
|
|
||||||
CONF_ADAPTER = "adapter"
|
CONF_ADAPTER = "adapter"
|
||||||
CONF_DETAILS = "details"
|
CONF_DETAILS = "details"
|
||||||
# CONF_PASSIVE is the legacy boolean option; we keep writing it alongside
|
|
||||||
# CONF_MODE so a downgrade to a pre-AUTO release reads a sensible value.
|
|
||||||
CONF_PASSIVE = "passive"
|
CONF_PASSIVE = "passive"
|
||||||
|
|
||||||
DEFAULT_MODE = BluetoothScanningMode.AUTO.value
|
|
||||||
|
|
||||||
|
# pylint: disable-next=home-assistant-duplicate-const
|
||||||
|
CONF_SOURCE: Final = "source"
|
||||||
CONF_SOURCE_DOMAIN: Final = "source_domain"
|
CONF_SOURCE_DOMAIN: Final = "source_domain"
|
||||||
CONF_SOURCE_MODEL: Final = "source_model"
|
CONF_SOURCE_MODEL: Final = "source_model"
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
|
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ from habluetooth import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.const import (
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED
|
||||||
CONF_SOURCE,
|
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
|
||||||
EVENT_LOGGING_CHANGED,
|
|
||||||
)
|
|
||||||
from homeassistant.core import (
|
from homeassistant.core import (
|
||||||
CALLBACK_TYPE,
|
CALLBACK_TYPE,
|
||||||
Event,
|
Event,
|
||||||
@@ -37,6 +33,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
|||||||
from homeassistant.util.package import is_docker_env
|
from homeassistant.util.package import is_docker_env
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
CONF_SOURCE,
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||||
CONF_SOURCE_DEVICE_ID,
|
CONF_SOURCE_DEVICE_ID,
|
||||||
CONF_SOURCE_DOMAIN,
|
CONF_SOURCE_DOMAIN,
|
||||||
@@ -205,9 +202,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
|||||||
self,
|
self,
|
||||||
callback: BluetoothCallback,
|
callback: BluetoothCallback,
|
||||||
matcher: BluetoothCallbackMatcher | None,
|
matcher: BluetoothCallbackMatcher | None,
|
||||||
mode: BluetoothScanningMode = BluetoothScanningMode.ACTIVE,
|
|
||||||
scan_interval: float | None = None,
|
|
||||||
scan_duration: float | None = None,
|
|
||||||
) -> Callable[[], None]:
|
) -> Callable[[], None]:
|
||||||
"""Register a callback."""
|
"""Register a callback."""
|
||||||
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
|
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
|
||||||
@@ -222,31 +216,15 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
|||||||
connectable = callback_matcher[CONNECTABLE]
|
connectable = callback_matcher[CONNECTABLE]
|
||||||
self._callback_index.add_callback_matcher(callback_matcher)
|
self._callback_index.add_callback_matcher(callback_matcher)
|
||||||
|
|
||||||
# If the matcher targets a specific address and the caller
|
|
||||||
# didn't explicitly ask for PASSIVE, wire it into habluetooth's
|
|
||||||
# active-scan scheduler so AUTO-mode scanners flip ACTIVE on
|
|
||||||
# demand for this device. ``scan_interval``/``scan_duration``
|
|
||||||
# default to habluetooth's DEFAULT_ACTIVE_SCAN_* when None.
|
|
||||||
cancel_active_scan: Callable[[], None] | None = None
|
|
||||||
if (
|
|
||||||
mode is not BluetoothScanningMode.PASSIVE
|
|
||||||
and (address := callback_matcher.get(ADDRESS)) is not None
|
|
||||||
):
|
|
||||||
cancel_active_scan = self.async_register_active_scan(
|
|
||||||
address, scan_interval, scan_duration
|
|
||||||
)
|
|
||||||
|
|
||||||
def _async_remove_callback() -> None:
|
def _async_remove_callback() -> None:
|
||||||
self._callback_index.remove_callback_matcher(callback_matcher)
|
self._callback_index.remove_callback_matcher(callback_matcher)
|
||||||
if cancel_active_scan is not None:
|
|
||||||
cancel_active_scan()
|
|
||||||
|
|
||||||
# If we have history for the subscriber, we can trigger the callback
|
# If we have history for the subscriber, we can trigger the callback
|
||||||
# immediately with the last packet so the subscriber can see the
|
# immediately with the last packet so the subscriber can see the
|
||||||
# device.
|
# device.
|
||||||
history = self._connectable_history if connectable else self._all_history
|
history = self._connectable_history if connectable else self._all_history
|
||||||
service_infos: Iterable[BluetoothServiceInfoBleak] = []
|
service_infos: Iterable[BluetoothServiceInfoBleak] = []
|
||||||
if (address := callback_matcher.get(ADDRESS)) is not None:
|
if address := callback_matcher.get(ADDRESS):
|
||||||
if service_info := history.get(address):
|
if service_info := history.get(address):
|
||||||
service_infos = [service_info]
|
service_infos = [service_info]
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -15,12 +15,12 @@
|
|||||||
],
|
],
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"bleak==3.0.2",
|
"bleak==2.1.1",
|
||||||
"bleak-retry-connector==4.6.1",
|
"bleak-retry-connector==4.6.0",
|
||||||
"bluetooth-adapters==2.3.0",
|
"bluetooth-adapters==2.1.0",
|
||||||
"bluetooth-auto-recovery==1.6.4",
|
"bluetooth-auto-recovery==1.5.3",
|
||||||
"bluetooth-data-tools==1.29.18",
|
"bluetooth-data-tools==1.29.11",
|
||||||
"dbus-fast==5.0.14",
|
"dbus-fast==5.0.3",
|
||||||
"habluetooth==6.7.4"
|
"habluetooth==6.4.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,13 +298,9 @@ class PassiveBluetoothProcessorCoordinator[_DataT](BasePassiveBluetoothCoordinat
|
|||||||
mode: BluetoothScanningMode,
|
mode: BluetoothScanningMode,
|
||||||
update_method: Callable[[BluetoothServiceInfoBleak], _DataT],
|
update_method: Callable[[BluetoothServiceInfoBleak], _DataT],
|
||||||
connectable: bool = False,
|
connectable: bool = False,
|
||||||
scan_interval: float | None = None,
|
|
||||||
scan_duration: float | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the coordinator."""
|
"""Initialize the coordinator."""
|
||||||
super().__init__(
|
super().__init__(hass, logger, address, mode, connectable)
|
||||||
hass, logger, address, mode, connectable, scan_interval, scan_duration
|
|
||||||
)
|
|
||||||
self._processors: list[PassiveBluetoothDataProcessor[Any, _DataT]] = []
|
self._processors: list[PassiveBluetoothDataProcessor[Any, _DataT]] = []
|
||||||
self._update_method = update_method
|
self._update_method = update_method
|
||||||
self.last_update_success = True
|
self.last_update_success = True
|
||||||
|
|||||||
@@ -48,21 +48,9 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"data": {
|
"data": {
|
||||||
"mode": "Scanning mode"
|
"passive": "Passive scanning"
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"selector": {
|
|
||||||
"mode": {
|
|
||||||
"options": {
|
|
||||||
"active": "Active (uses more device battery, fastest updates)",
|
|
||||||
"auto": "Auto (recommended, saves device battery)",
|
|
||||||
"passive": "Passive (lowest device battery use, some details may be missing)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ class BasePassiveBluetoothCoordinator(ABC):
|
|||||||
address: str,
|
address: str,
|
||||||
mode: BluetoothScanningMode,
|
mode: BluetoothScanningMode,
|
||||||
connectable: bool,
|
connectable: bool,
|
||||||
scan_interval: float | None = None,
|
|
||||||
scan_duration: float | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the coordinator."""
|
"""Initialize the coordinator."""
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
@@ -40,8 +38,6 @@ class BasePassiveBluetoothCoordinator(ABC):
|
|||||||
self.connectable = connectable
|
self.connectable = connectable
|
||||||
self._on_stop: list[CALLBACK_TYPE] = []
|
self._on_stop: list[CALLBACK_TYPE] = []
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self._scan_interval = scan_interval
|
|
||||||
self._scan_duration = scan_duration
|
|
||||||
self._last_unavailable_time = 0.0
|
self._last_unavailable_time = 0.0
|
||||||
self._last_name = address
|
self._last_name = address
|
||||||
# Subclasses are responsible for setting _available to True
|
# Subclasses are responsible for setting _available to True
|
||||||
@@ -96,8 +92,6 @@ class BasePassiveBluetoothCoordinator(ABC):
|
|||||||
address=self.address, connectable=self.connectable
|
address=self.address, connectable=self.connectable
|
||||||
),
|
),
|
||||||
self.mode,
|
self.mode,
|
||||||
scan_interval=self._scan_interval,
|
|
||||||
scan_duration=self._scan_duration,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._on_stop.append(
|
self._on_stop.append(
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
"""The bluetooth integration utilities."""
|
"""The bluetooth integration utilities."""
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from bluetooth_adapters import (
|
from bluetooth_adapters import (
|
||||||
ADAPTER_ADDRESS,
|
ADAPTER_ADDRESS,
|
||||||
ADAPTER_MANUFACTURER,
|
ADAPTER_MANUFACTURER,
|
||||||
@@ -13,32 +9,14 @@ from bluetooth_adapters import (
|
|||||||
adapter_unique_name,
|
adapter_unique_name,
|
||||||
)
|
)
|
||||||
from bluetooth_data_tools import monotonic_time_coarse
|
from bluetooth_data_tools import monotonic_time_coarse
|
||||||
from habluetooth import BluetoothScanningMode, get_manager
|
from habluetooth import get_manager
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from .const import CONF_MODE, CONF_PASSIVE, DEFAULT_MODE
|
|
||||||
from .models import BluetoothServiceInfoBleak
|
from .models import BluetoothServiceInfoBleak
|
||||||
from .storage import BluetoothStorage
|
from .storage import BluetoothStorage
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_scanning_mode(options: Mapping[str, Any]) -> BluetoothScanningMode:
|
|
||||||
"""Resolve CONF_MODE, falling back to legacy CONF_PASSIVE or DEFAULT_MODE."""
|
|
||||||
if (mode_value := options.get(CONF_MODE)) is not None:
|
|
||||||
try:
|
|
||||||
return BluetoothScanningMode(mode_value)
|
|
||||||
except TypeError, ValueError:
|
|
||||||
_LOGGER.warning("Unknown bluetooth scanning mode %r", mode_value)
|
|
||||||
return BluetoothScanningMode(DEFAULT_MODE)
|
|
||||||
if (legacy_passive := options.get(CONF_PASSIVE)) is True:
|
|
||||||
return BluetoothScanningMode.PASSIVE
|
|
||||||
if legacy_passive is False:
|
|
||||||
return BluetoothScanningMode.ACTIVE
|
|
||||||
return BluetoothScanningMode(DEFAULT_MODE)
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidConfigEntryID(HomeAssistantError):
|
class InvalidConfigEntryID(HomeAssistantError):
|
||||||
"""Invalid config entry id."""
|
"""Invalid config entry id."""
|
||||||
|
|||||||
@@ -9,14 +9,7 @@ from pybravia import BraviaAuthError, BraviaClient, BraviaError, BraviaNotSuppor
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import (
|
from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN
|
||||||
ATTR_MODEL,
|
|
||||||
CONF_CLIENT_ID,
|
|
||||||
CONF_HOST,
|
|
||||||
CONF_MAC,
|
|
||||||
CONF_NAME,
|
|
||||||
CONF_PIN,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers import instance_id
|
from homeassistant.helpers import instance_id
|
||||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||||
from homeassistant.helpers.service_info.ssdp import (
|
from homeassistant.helpers.service_info.ssdp import (
|
||||||
@@ -30,6 +23,7 @@ from homeassistant.util.network import is_host_valid
|
|||||||
from .const import (
|
from .const import (
|
||||||
ATTR_CID,
|
ATTR_CID,
|
||||||
ATTR_MAC,
|
ATTR_MAC,
|
||||||
|
ATTR_MODEL,
|
||||||
CONF_NICKNAME,
|
CONF_NICKNAME,
|
||||||
CONF_USE_PSK,
|
CONF_USE_PSK,
|
||||||
CONF_USE_SSL,
|
CONF_USE_SSL,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from typing import Final
|
|||||||
ATTR_CID: Final = "cid"
|
ATTR_CID: Final = "cid"
|
||||||
ATTR_MAC: Final = "macAddr"
|
ATTR_MAC: Final = "macAddr"
|
||||||
ATTR_MANUFACTURER: Final = "Sony"
|
ATTR_MANUFACTURER: Final = "Sony"
|
||||||
|
# pylint: disable-next=home-assistant-duplicate-const
|
||||||
|
ATTR_MODEL: Final = "model"
|
||||||
|
|
||||||
CONF_NICKNAME: Final = "nickname"
|
CONF_NICKNAME: Final = "nickname"
|
||||||
CONF_USE_PSK: Final = "use_psk"
|
CONF_USE_PSK: Final = "use_psk"
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ BTHOME_BLE_EVENT: Final = "bthome_ble_event"
|
|||||||
|
|
||||||
EVENT_CLASS_BUTTON: Final = "button"
|
EVENT_CLASS_BUTTON: Final = "button"
|
||||||
EVENT_CLASS_DIMMER: Final = "dimmer"
|
EVENT_CLASS_DIMMER: Final = "dimmer"
|
||||||
EVENT_CLASS_COMMAND: Final = "command"
|
|
||||||
|
|
||||||
CONF_EVENT_CLASS: Final = "event_class"
|
CONF_EVENT_CLASS: Final = "event_class"
|
||||||
CONF_EVENT_PROPERTIES: Final = "event_properties"
|
CONF_EVENT_PROPERTIES: Final = "event_properties"
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ from .const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
EVENT_CLASS,
|
EVENT_CLASS,
|
||||||
EVENT_CLASS_BUTTON,
|
EVENT_CLASS_BUTTON,
|
||||||
EVENT_CLASS_COMMAND,
|
|
||||||
EVENT_CLASS_DIMMER,
|
EVENT_CLASS_DIMMER,
|
||||||
EVENT_TYPE,
|
EVENT_TYPE,
|
||||||
)
|
)
|
||||||
@@ -44,7 +43,6 @@ EVENT_TYPES_BY_EVENT_CLASS = {
|
|||||||
"hold_press",
|
"hold_press",
|
||||||
},
|
},
|
||||||
EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"},
|
EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"},
|
||||||
EVENT_CLASS_COMMAND: {"off", "on", "toggle", "step_up", "step_down"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from . import format_discovered_event_class, format_event_dispatcher_name
|
|||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
EVENT_CLASS_BUTTON,
|
EVENT_CLASS_BUTTON,
|
||||||
EVENT_CLASS_COMMAND,
|
|
||||||
EVENT_CLASS_DIMMER,
|
EVENT_CLASS_DIMMER,
|
||||||
EVENT_PROPERTIES,
|
EVENT_PROPERTIES,
|
||||||
EVENT_TYPE,
|
EVENT_TYPE,
|
||||||
@@ -44,11 +43,6 @@ DESCRIPTIONS_BY_EVENT_CLASS = {
|
|||||||
translation_key="dimmer",
|
translation_key="dimmer",
|
||||||
event_types=["rotate_left", "rotate_right"],
|
event_types=["rotate_left", "rotate_right"],
|
||||||
),
|
),
|
||||||
EVENT_CLASS_COMMAND: EventEntityDescription(
|
|
||||||
key=EVENT_CLASS_COMMAND,
|
|
||||||
translation_key="command",
|
|
||||||
event_types=["off", "on", "toggle", "step_up", "step_down"],
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,5 @@
|
|||||||
"dependencies": ["bluetooth_adapters"],
|
"dependencies": ["bluetooth_adapters"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["bthome-ble==3.23.2"]
|
"requirements": ["bthome-ble==3.17.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,12 +192,6 @@ SENSOR_DESCRIPTIONS = {
|
|||||||
native_unit_of_measurement=LIGHT_LUX,
|
native_unit_of_measurement=LIGHT_LUX,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
),
|
||||||
# Light level (-)
|
|
||||||
(BTHomeExtendedSensorDeviceClass.LIGHT_LEVEL, None): SensorEntityDescription(
|
|
||||||
key=str(BTHomeExtendedSensorDeviceClass.LIGHT_LEVEL),
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
translation_key="light_level",
|
|
||||||
),
|
|
||||||
# Mass sensor (kg)
|
# Mass sensor (kg)
|
||||||
(BTHomeSensorDeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription(
|
(BTHomeSensorDeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription(
|
||||||
key=f"{BTHomeSensorDeviceClass.MASS}_{Units.MASS_KILOGRAMS}",
|
key=f"{BTHomeSensorDeviceClass.MASS}_{Units.MASS_KILOGRAMS}",
|
||||||
@@ -293,12 +287,6 @@ SENSOR_DESCRIPTIONS = {
|
|||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
translation_key="rotational_speed",
|
translation_key="rotational_speed",
|
||||||
),
|
),
|
||||||
# Settings revision (-)
|
|
||||||
(BTHomeExtendedSensorDeviceClass.SETTINGS_REVISION, None): SensorEntityDescription(
|
|
||||||
key=str(BTHomeExtendedSensorDeviceClass.SETTINGS_REVISION),
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
translation_key="settings_revision",
|
|
||||||
),
|
|
||||||
# Signal Strength (RSSI) (dB)
|
# Signal Strength (RSSI) (dB)
|
||||||
(
|
(
|
||||||
BTHomeSensorDeviceClass.SIGNAL_STRENGTH,
|
BTHomeSensorDeviceClass.SIGNAL_STRENGTH,
|
||||||
|
|||||||
@@ -36,19 +36,13 @@
|
|||||||
"long_double_press": "Long Double Press",
|
"long_double_press": "Long Double Press",
|
||||||
"long_press": "Long Press",
|
"long_press": "Long Press",
|
||||||
"long_triple_press": "Long Triple Press",
|
"long_triple_press": "Long Triple Press",
|
||||||
"off": "Off",
|
|
||||||
"on": "On",
|
|
||||||
"press": "Press",
|
"press": "Press",
|
||||||
"rotate_left": "Rotate Left",
|
"rotate_left": "Rotate Left",
|
||||||
"rotate_right": "Rotate Right",
|
"rotate_right": "Rotate Right",
|
||||||
"step_down": "Step Down",
|
|
||||||
"step_up": "Step Up",
|
|
||||||
"toggle": "Toggle",
|
|
||||||
"triple_press": "Triple Press"
|
"triple_press": "Triple Press"
|
||||||
},
|
},
|
||||||
"trigger_type": {
|
"trigger_type": {
|
||||||
"button": "Button \"{subtype}\"",
|
"button": "Button \"{subtype}\"",
|
||||||
"command": "Command \"{subtype}\"",
|
|
||||||
"dimmer": "Dimmer \"{subtype}\""
|
"dimmer": "Dimmer \"{subtype}\""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -74,19 +68,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"command": {
|
|
||||||
"state_attributes": {
|
|
||||||
"event_type": {
|
|
||||||
"state": {
|
|
||||||
"off": "Off",
|
|
||||||
"on": "On",
|
|
||||||
"step_down": "Step down",
|
|
||||||
"step_up": "Step up",
|
|
||||||
"toggle": "Toggle"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dimmer": {
|
"dimmer": {
|
||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"event_type": {
|
"event_type": {
|
||||||
@@ -117,9 +98,6 @@
|
|||||||
"gyroscope": {
|
"gyroscope": {
|
||||||
"name": "Gyroscope"
|
"name": "Gyroscope"
|
||||||
},
|
},
|
||||||
"light_level": {
|
|
||||||
"name": "Light level"
|
|
||||||
},
|
|
||||||
"packet_id": {
|
"packet_id": {
|
||||||
"name": "Packet ID"
|
"name": "Packet ID"
|
||||||
},
|
},
|
||||||
@@ -132,9 +110,6 @@
|
|||||||
"rotational_speed": {
|
"rotational_speed": {
|
||||||
"name": "Rotational speed"
|
"name": "Rotational speed"
|
||||||
},
|
},
|
||||||
"settings_revision": {
|
|
||||||
"name": "Settings revision"
|
|
||||||
},
|
|
||||||
"text": {
|
"text": {
|
||||||
"name": "Text"
|
"name": "Text"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,16 +32,8 @@ OPTIONS_SCHEMA = KNOWN_HOSTS_SCHEMA.extend(
|
|||||||
vol.Required(CONF_MORE_OPTIONS): section(
|
vol.Required(CONF_MORE_OPTIONS): section(
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(CONF_UUID): SelectSelector(
|
vol.Optional(CONF_UUID): str,
|
||||||
SelectSelectorConfig(
|
vol.Optional(CONF_IGNORE_CEC): str,
|
||||||
custom_value=True, options=[], multiple=True
|
|
||||||
),
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_IGNORE_CEC): SelectSelector(
|
|
||||||
SelectSelectorConfig(
|
|
||||||
custom_value=True, options=[], multiple=True
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
SectionConfig(collapsed=True),
|
SectionConfig(collapsed=True),
|
||||||
@@ -117,11 +109,13 @@ class CastOptionsFlowHandler(OptionsFlow):
|
|||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Manage the Google Cast options."""
|
"""Manage the Google Cast options."""
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
ignore_cec = _trim_items(
|
ignore_cec = _string_to_list(
|
||||||
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, [])
|
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, "")
|
||||||
)
|
)
|
||||||
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
|
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
|
||||||
wanted_uuid = _trim_items(user_input[CONF_MORE_OPTIONS].get(CONF_UUID, []))
|
wanted_uuid = _string_to_list(
|
||||||
|
user_input[CONF_MORE_OPTIONS].get(CONF_UUID, "")
|
||||||
|
)
|
||||||
updated_config = dict(self.config_entry.data)
|
updated_config = dict(self.config_entry.data)
|
||||||
updated_config[CONF_IGNORE_CEC] = ignore_cec
|
updated_config[CONF_IGNORE_CEC] = ignore_cec
|
||||||
updated_config[CONF_KNOWN_HOSTS] = known_hosts
|
updated_config[CONF_KNOWN_HOSTS] = known_hosts
|
||||||
@@ -138,7 +132,9 @@ class CastOptionsFlowHandler(OptionsFlow):
|
|||||||
for key in (CONF_UUID, CONF_IGNORE_CEC):
|
for key in (CONF_UUID, CONF_IGNORE_CEC):
|
||||||
if key not in self.config_entry.data:
|
if key not in self.config_entry.data:
|
||||||
continue
|
continue
|
||||||
suggested[CONF_MORE_OPTIONS][key] = self.config_entry.data[key]
|
suggested[CONF_MORE_OPTIONS][key] = _list_to_string(
|
||||||
|
self.config_entry.data[key]
|
||||||
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
@@ -147,5 +143,16 @@ class CastOptionsFlowHandler(OptionsFlow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_to_string(items: list[str]) -> str:
|
||||||
|
comma_separated_string = ""
|
||||||
|
if items:
|
||||||
|
comma_separated_string = ",".join(items)
|
||||||
|
return comma_separated_string
|
||||||
|
|
||||||
|
|
||||||
|
def _string_to_list(string: str) -> list[str]:
|
||||||
|
return [x.strip() for x in string.split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
def _trim_items(items: list[str]) -> list[str]:
|
def _trim_items(items: list[str]) -> list[str]:
|
||||||
return [x.strip() for x in items if x.strip()]
|
return [x.strip() for x in items if x.strip()]
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
"""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),
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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,10 +16,6 @@
|
|||||||
"host": "[%key:common::config_flow::data::host%]",
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
"port": "[%key:common::config_flow::data::port%]"
|
"port": "[%key:common::config_flow::data::port%]"
|
||||||
},
|
},
|
||||||
"data_description": {
|
|
||||||
"host": "The hostname or IP address of the server to monitor.",
|
|
||||||
"port": "The port to connect to on the server."
|
|
||||||
},
|
|
||||||
"title": "Reconfigure the certificate to test"
|
"title": "Reconfigure the certificate to test"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -28,10 +24,6 @@
|
|||||||
"name": "The name of the certificate",
|
"name": "The name of the certificate",
|
||||||
"port": "[%key:common::config_flow::data::port%]"
|
"port": "[%key:common::config_flow::data::port%]"
|
||||||
},
|
},
|
||||||
"data_description": {
|
|
||||||
"host": "The hostname or IP address of the server to monitor.",
|
|
||||||
"port": "The port to connect to on the server."
|
|
||||||
},
|
|
||||||
"title": "Define the certificate to test"
|
"title": "Define the certificate to test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from homeassistant.components.notify import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_API_KEY,
|
CONF_API_KEY,
|
||||||
CONF_LANGUAGE,
|
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
CONF_RECIPIENT,
|
CONF_RECIPIENT,
|
||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
@@ -30,6 +29,8 @@ BASE_API_URL = "https://rest.clicksend.com/v3"
|
|||||||
|
|
||||||
HEADERS = {"Content-Type": CONTENT_TYPE_JSON}
|
HEADERS = {"Content-Type": CONTENT_TYPE_JSON}
|
||||||
|
|
||||||
|
# pylint: disable-next=home-assistant-duplicate-const
|
||||||
|
CONF_LANGUAGE = "language"
|
||||||
CONF_VOICE = "voice"
|
CONF_VOICE = "voice"
|
||||||
|
|
||||||
MALE_VOICE = "male"
|
MALE_VOICE = "male"
|
||||||
|
|||||||
@@ -275,13 +275,9 @@ class CloudGoogleConfig(AbstractConfig):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def should_expose(self, entity_id: str) -> bool:
|
def should_expose(self, state: State) -> bool:
|
||||||
"""If an entity should be exposed."""
|
"""If a state object should be exposed."""
|
||||||
entity_filter: EntityFilter = self._config[CONF_FILTER]
|
return self._should_expose_entity_id(state.entity_id)
|
||||||
if not entity_filter.empty_filter:
|
|
||||||
return entity_filter(entity_id)
|
|
||||||
|
|
||||||
return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)
|
|
||||||
|
|
||||||
def _should_expose_legacy(self, entity_id: str) -> bool:
|
def _should_expose_legacy(self, entity_id: str) -> bool:
|
||||||
"""If an entity ID should be exposed."""
|
"""If an entity ID should be exposed."""
|
||||||
@@ -312,6 +308,14 @@ class CloudGoogleConfig(AbstractConfig):
|
|||||||
and _supported_legacy(self.hass, entity_id)
|
and _supported_legacy(self.hass, entity_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _should_expose_entity_id(self, entity_id: str) -> bool:
|
||||||
|
"""If an entity should be exposed."""
|
||||||
|
entity_filter: EntityFilter = self._config[CONF_FILTER]
|
||||||
|
if not entity_filter.empty_filter:
|
||||||
|
return entity_filter(entity_id)
|
||||||
|
|
||||||
|
return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def agent_user_id(self) -> str:
|
def agent_user_id(self) -> str:
|
||||||
"""Return Agent User Id to use for query responses."""
|
"""Return Agent User Id to use for query responses."""
|
||||||
@@ -463,7 +467,7 @@ class CloudGoogleConfig(AbstractConfig):
|
|||||||
|
|
||||||
entity_id = event.data["entity_id"]
|
entity_id = event.data["entity_id"]
|
||||||
|
|
||||||
if not self.should_expose(entity_id):
|
if not self._should_expose_entity_id(entity_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
self.async_schedule_google_sync_all()
|
self.async_schedule_google_sync_all()
|
||||||
@@ -486,7 +490,8 @@ class CloudGoogleConfig(AbstractConfig):
|
|||||||
|
|
||||||
# Check if any exposed entity uses the device area
|
# Check if any exposed entity uses the device area
|
||||||
if not any(
|
if not any(
|
||||||
entity_entry.area_id is None and self.should_expose(entity_entry.entity_id)
|
entity_entry.area_id is None
|
||||||
|
and self._should_expose_entity_id(entity_entry.entity_id)
|
||||||
for entity_entry in er.async_entries_for_device(
|
for entity_entry in er.async_entries_for_device(
|
||||||
er.async_get(self.hass), event.data["device_id"]
|
er.async_get(self.hass), event.data["device_id"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ from homeassistant.components.backup import (
|
|||||||
OnProgressCallback,
|
OnProgressCallback,
|
||||||
suggested_filename,
|
suggested_filename,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_PREFIX
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
from . import R2ConfigEntry
|
from . import R2ConfigEntry
|
||||||
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
CACHE_TTL = 300
|
CACHE_TTL = 300
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from botocore.exceptions import (
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_PREFIX
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.selector import (
|
from homeassistant.helpers.selector import (
|
||||||
TextSelector,
|
TextSelector,
|
||||||
@@ -26,6 +25,7 @@ from .const import (
|
|||||||
CONF_ACCESS_KEY_ID,
|
CONF_ACCESS_KEY_ID,
|
||||||
CONF_BUCKET,
|
CONF_BUCKET,
|
||||||
CONF_ENDPOINT_URL,
|
CONF_ENDPOINT_URL,
|
||||||
|
CONF_PREFIX,
|
||||||
CONF_SECRET_ACCESS_KEY,
|
CONF_SECRET_ACCESS_KEY,
|
||||||
DEFAULT_ENDPOINT_URL,
|
DEFAULT_ENDPOINT_URL,
|
||||||
DESCRIPTION_R2_AUTH_DOCS_URL,
|
DESCRIPTION_R2_AUTH_DOCS_URL,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ CONF_ACCESS_KEY_ID = "access_key_id"
|
|||||||
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||||
CONF_ENDPOINT_URL = "endpoint_url"
|
CONF_ENDPOINT_URL = "endpoint_url"
|
||||||
CONF_BUCKET = "bucket"
|
CONF_BUCKET = "bucket"
|
||||||
|
# pylint: disable-next=home-assistant-duplicate-const
|
||||||
|
CONF_PREFIX = "prefix"
|
||||||
|
|
||||||
# R2 is S3-compatible. Endpoint should be like:
|
# R2 is S3-compatible. Endpoint should be like:
|
||||||
# https://<accountid>.r2.cloudflarestorage.com
|
# https://<accountid>.r2.cloudflarestorage.com
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ ATTR_URL = "color_extract_url"
|
|||||||
DOMAIN = "color_extractor"
|
DOMAIN = "color_extractor"
|
||||||
DEFAULT_NAME = "Color extractor"
|
DEFAULT_NAME = "Color extractor"
|
||||||
|
|
||||||
SERVICE_GET_COLOR = "get_color"
|
# pylint: disable-next=home-assistant-duplicate-const
|
||||||
|
SERVICE_TURN_ON = "turn_on"
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"services": {
|
"services": {
|
||||||
"get_color": {
|
|
||||||
"service": "mdi:select-color"
|
|
||||||
},
|
|
||||||
"turn_on": {
|
"turn_on": {
|
||||||
"service": "mdi:lightbulb-on"
|
"service": "mdi:lightbulb-on"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from colorthief import ColorThief
|
from colorthief import ColorThief
|
||||||
@@ -15,17 +14,16 @@ from homeassistant.components.light import (
|
|||||||
DOMAIN as LIGHT_DOMAIN,
|
DOMAIN as LIGHT_DOMAIN,
|
||||||
LIGHT_TURN_ON_SCHEMA,
|
LIGHT_TURN_ON_SCHEMA,
|
||||||
)
|
)
|
||||||
from homeassistant.const import SERVICE_TURN_ON
|
from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
from homeassistant.exceptions import ServiceValidationError
|
|
||||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||||
|
|
||||||
from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_GET_COLOR
|
from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Extend the existing light.turn_on service schema
|
# Extend the existing light.turn_on service schema
|
||||||
TURN_ON_SERVICE_SCHEMA = vol.All(
|
SERVICE_SCHEMA = vol.All(
|
||||||
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
|
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
|
||||||
cv.make_entity_service_schema(
|
cv.make_entity_service_schema(
|
||||||
{
|
{
|
||||||
@@ -36,14 +34,6 @@ TURN_ON_SERVICE_SCHEMA = vol.All(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
GET_COLOR_SERVICE_SCHEMA = vol.All(
|
|
||||||
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
|
|
||||||
{
|
|
||||||
vol.Exclusive(ATTR_PATH, "color_extractor"): cv.isfile,
|
|
||||||
vol.Exclusive(ATTR_URL, "color_extractor"): cv.url,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_file(file_path: str) -> str:
|
def _get_file(file_path: str) -> str:
|
||||||
"""Get a PIL acceptable input file reference.
|
"""Get a PIL acceptable input file reference.
|
||||||
@@ -151,54 +141,10 @@ async def async_handle_service(service_call: ServiceCall) -> None:
|
|||||||
service_data[ATTR_RGB_COLOR] = color
|
service_data[ATTR_RGB_COLOR] = color
|
||||||
|
|
||||||
await service_call.hass.services.async_call(
|
await service_call.hass.services.async_call(
|
||||||
LIGHT_DOMAIN, SERVICE_TURN_ON, service_data, blocking=True
|
LIGHT_DOMAIN, LIGHT_SERVICE_TURN_ON, service_data, blocking=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_handle_get_color(
|
|
||||||
service_call: ServiceCall,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Handle get_color service call."""
|
|
||||||
service_data = dict(service_call.data)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if ATTR_URL in service_data:
|
|
||||||
image_type = "URL"
|
|
||||||
image_reference = service_data.pop(ATTR_URL)
|
|
||||||
color = await _async_extract_color_from_url(
|
|
||||||
service_call.hass, image_reference
|
|
||||||
)
|
|
||||||
|
|
||||||
elif ATTR_PATH in service_data:
|
|
||||||
image_type = "file path"
|
|
||||||
image_reference = service_data.pop(ATTR_PATH)
|
|
||||||
color = await service_call.hass.async_add_executor_job(
|
|
||||||
_extract_color_from_path, service_call.hass, image_reference
|
|
||||||
)
|
|
||||||
|
|
||||||
except UnidentifiedImageError as ex:
|
|
||||||
raise ServiceValidationError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="invalid_image",
|
|
||||||
translation_placeholders={
|
|
||||||
"image_type": image_type,
|
|
||||||
"image_reference": image_reference,
|
|
||||||
},
|
|
||||||
) from ex
|
|
||||||
|
|
||||||
if color is None:
|
|
||||||
raise ServiceValidationError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="invalid_image",
|
|
||||||
translation_placeholders={
|
|
||||||
"image_type": image_type,
|
|
||||||
"image_reference": image_reference,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"color": color}
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_setup_services(hass: HomeAssistant) -> None:
|
def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
"""Register the services."""
|
"""Register the services."""
|
||||||
@@ -207,13 +153,5 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_TURN_ON,
|
SERVICE_TURN_ON,
|
||||||
async_handle_service,
|
async_handle_service,
|
||||||
schema=TURN_ON_SERVICE_SCHEMA,
|
schema=SERVICE_SCHEMA,
|
||||||
)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_GET_COLOR,
|
|
||||||
async_handle_get_color,
|
|
||||||
schema=GET_COLOR_SERVICE_SCHEMA,
|
|
||||||
supports_response=SupportsResponse.ONLY,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,13 +11,3 @@ turn_on:
|
|||||||
example: /opt/images/logo.png
|
example: /opt/images/logo.png
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
get_color:
|
|
||||||
fields:
|
|
||||||
color_extract_url:
|
|
||||||
example: https://www.example.com/images/logo.png
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
color_extract_path:
|
|
||||||
example: /opt/images/logo.png
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
|
|||||||
@@ -6,26 +6,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exceptions": {
|
|
||||||
"invalid_image": {
|
|
||||||
"message": "Bad image {image_reference} from {image_type} provided, are you sure it's an image?"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"services": {
|
"services": {
|
||||||
"get_color": {
|
|
||||||
"description": "Gets the predominant RGB color found in the image provided by URL or file path.",
|
|
||||||
"fields": {
|
|
||||||
"color_extract_path": {
|
|
||||||
"description": "The full system path to the image we want to extract RGB values from. Must be allowed in allowlist_external_dirs.",
|
|
||||||
"name": "[%key:common::config_flow::data::path%]"
|
|
||||||
},
|
|
||||||
"color_extract_url": {
|
|
||||||
"description": "The URL of the image we want to extract RGB values from. Must be allowed in allowlist_external_urls.",
|
|
||||||
"name": "[%key:common::config_flow::data::url%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"name": "Get predominant color"
|
|
||||||
},
|
|
||||||
"turn_on": {
|
"turn_on": {
|
||||||
"description": "Sets the light RGB to the predominant color found in the image provided by URL or file path.",
|
"description": "Sets the light RGB to the predominant color found in the image provided by URL or file path.",
|
||||||
"fields": {
|
"fields": {
|
||||||
|
|||||||
@@ -60,9 +60,7 @@ class CheckConfigView(HomeAssistantView):
|
|||||||
vol.Optional("location_name"): str,
|
vol.Optional("location_name"): str,
|
||||||
vol.Optional("longitude"): cv.longitude,
|
vol.Optional("longitude"): cv.longitude,
|
||||||
vol.Optional("radius"): cv.positive_int,
|
vol.Optional("radius"): cv.positive_int,
|
||||||
# Validated by async_set_time_zone in the executor to avoid
|
vol.Optional("time_zone"): cv.time_zone,
|
||||||
# blocking I/O loading zoneinfo data on the event loop.
|
|
||||||
vol.Optional("time_zone"): str,
|
|
||||||
vol.Optional("update_units"): bool,
|
vol.Optional("update_units"): bool,
|
||||||
vol.Optional("unit_system"): unit_system.validate_unit_system,
|
vol.Optional("unit_system"): unit_system.validate_unit_system,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,35 +42,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) ->
|
|||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
def _migrate_identifiers(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: CookidooConfigEntry,
|
|
||||||
old_prefix: str,
|
|
||||||
new_unique_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""Migrate device identifiers and entity unique_ids from old to new prefix."""
|
|
||||||
device_registry = dr.async_get(hass)
|
|
||||||
entity_registry = er.async_get(hass)
|
|
||||||
device_entries = dr.async_entries_for_config_entry(
|
|
||||||
device_registry, config_entry_id=config_entry.entry_id
|
|
||||||
)
|
|
||||||
entity_entries = er.async_entries_for_config_entry(
|
|
||||||
entity_registry, config_entry_id=config_entry.entry_id
|
|
||||||
)
|
|
||||||
for dev in device_entries:
|
|
||||||
new_identifiers = {
|
|
||||||
(DOMAIN, new_unique_id) if domain == DOMAIN else (domain, identifier)
|
|
||||||
for domain, identifier in dev.identifiers
|
|
||||||
}
|
|
||||||
device_registry.async_update_device(dev.id, new_identifiers=new_identifiers)
|
|
||||||
for ent in entity_entries:
|
|
||||||
if ent.unique_id and ent.unique_id.startswith(f"{old_prefix}_"):
|
|
||||||
entity_registry.async_update_entity(
|
|
||||||
ent.entity_id,
|
|
||||||
new_unique_id=f"{new_unique_id}{ent.unique_id[len(old_prefix) :]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_migrate_entry(
|
async def async_migrate_entry(
|
||||||
hass: HomeAssistant, config_entry: CookidooConfigEntry
|
hass: HomeAssistant, config_entry: CookidooConfigEntry
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -78,37 +49,41 @@ async def async_migrate_entry(
|
|||||||
_LOGGER.debug("Migrating from version %s", config_entry.version)
|
_LOGGER.debug("Migrating from version %s", config_entry.version)
|
||||||
|
|
||||||
if config_entry.version == 1 and config_entry.minor_version == 1:
|
if config_entry.version == 1 and config_entry.minor_version == 1:
|
||||||
# Add the unique uuid (first migration, entities used config_entry_id as prefix)
|
# Add the unique uuid
|
||||||
cookidoo = await cookidoo_from_config_entry(hass, config_entry)
|
cookidoo = await cookidoo_from_config_entry(hass, config_entry)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await cookidoo.login()
|
auth_data = await cookidoo.login()
|
||||||
user_info = await cookidoo.get_user_info()
|
|
||||||
except (CookidooRequestException, CookidooAuthException) as e:
|
except (CookidooRequestException, CookidooAuthException) as e:
|
||||||
_LOGGER.error("Could not migrate config entry: %s", e)
|
_LOGGER.error(
|
||||||
|
"Could not migrate config config_entry: %s",
|
||||||
|
str(e),
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
_migrate_identifiers(hass, config_entry, config_entry.entry_id, user_info.id)
|
unique_id = auth_data.sub
|
||||||
hass.config_entries.async_update_entry(
|
|
||||||
config_entry, unique_id=user_info.id, minor_version=3
|
device_registry = dr.async_get(hass)
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
device_entries = dr.async_entries_for_config_entry(
|
||||||
|
device_registry, config_entry_id=config_entry.entry_id
|
||||||
)
|
)
|
||||||
|
entity_entries = er.async_entries_for_config_entry(
|
||||||
|
entity_registry, config_entry_id=config_entry.entry_id
|
||||||
|
)
|
||||||
|
for dev in device_entries:
|
||||||
|
device_registry.async_update_device(
|
||||||
|
dev.id, new_identifiers={(DOMAIN, unique_id)}
|
||||||
|
)
|
||||||
|
for ent in entity_entries:
|
||||||
|
assert ent.config_entry_id
|
||||||
|
entity_registry.async_update_entity(
|
||||||
|
ent.entity_id,
|
||||||
|
new_unique_id=ent.unique_id.replace(ent.config_entry_id, unique_id),
|
||||||
|
)
|
||||||
|
|
||||||
if config_entry.version == 1 and config_entry.minor_version == 2:
|
|
||||||
# Migrate unique_id from old CIAM sub to community profile id
|
|
||||||
cookidoo = await cookidoo_from_config_entry(hass, config_entry)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await cookidoo.login()
|
|
||||||
user_info = await cookidoo.get_user_info()
|
|
||||||
except (CookidooRequestException, CookidooAuthException) as e:
|
|
||||||
_LOGGER.error("Could not migrate config entry: %s", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
old_unique_id = config_entry.unique_id
|
|
||||||
if old_unique_id:
|
|
||||||
_migrate_identifiers(hass, config_entry, old_unique_id, user_info.id)
|
|
||||||
hass.config_entries.async_update_entry(
|
hass.config_entries.async_update_entry(
|
||||||
config_entry, unique_id=user_info.id, minor_version=3
|
config_entry, unique_id=auth_data.sub, minor_version=2
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from cookidoo_api import (
|
from cookidoo_api import CookidooAuthException, CookidooException
|
||||||
CookidooAuthException,
|
|
||||||
CookidooException,
|
|
||||||
CookidooRequestException,
|
|
||||||
)
|
|
||||||
from cookidoo_api.types import CookidooCalendarDayRecipe
|
from cookidoo_api.types import CookidooCalendarDayRecipe
|
||||||
|
|
||||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||||
@@ -78,13 +74,7 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
|
|||||||
week_day
|
week_day
|
||||||
)
|
)
|
||||||
except CookidooAuthException:
|
except CookidooAuthException:
|
||||||
try:
|
await self.coordinator.cookidoo.refresh_token()
|
||||||
await self.coordinator.cookidoo.login()
|
|
||||||
except (CookidooAuthException, CookidooRequestException) as exc:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="calendar_fetch_failed",
|
|
||||||
) from exc
|
|
||||||
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
|
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
|
||||||
week_day
|
week_day
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Handle a config flow for Cookidoo."""
|
"""Handle a config flow for Cookidoo."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
MINOR_VERSION = 3
|
MINOR_VERSION = 2
|
||||||
|
|
||||||
COUNTRY_DATA_SCHEMA: dict
|
COUNTRY_DATA_SCHEMA: dict
|
||||||
LANGUAGE_DATA_SCHEMA: dict
|
LANGUAGE_DATA_SCHEMA: dict
|
||||||
@@ -223,9 +223,8 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
cookidoo = await cookidoo_from_config_data(self.hass, data_input)
|
cookidoo = await cookidoo_from_config_data(self.hass, data_input)
|
||||||
try:
|
try:
|
||||||
await cookidoo.login()
|
auth_data = await cookidoo.login()
|
||||||
user_info = await cookidoo.get_user_info()
|
self.user_uuid = auth_data.sub
|
||||||
self.user_uuid = user_info.id
|
|
||||||
if language_input:
|
if language_input:
|
||||||
await cookidoo.get_additional_items()
|
await cookidoo.get_additional_items()
|
||||||
except CookidooRequestException:
|
except CookidooRequestException:
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
|||||||
)
|
)
|
||||||
except CookidooAuthException:
|
except CookidooAuthException:
|
||||||
try:
|
try:
|
||||||
await self.cookidoo.login()
|
await self.cookidoo.refresh_token()
|
||||||
except CookidooAuthException as exc:
|
except CookidooAuthException as exc:
|
||||||
raise ConfigEntryAuthFailed(
|
raise ConfigEntryAuthFailed(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
@@ -96,11 +96,6 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
|||||||
CONF_EMAIL: self.config_entry.data[CONF_EMAIL]
|
CONF_EMAIL: self.config_entry.data[CONF_EMAIL]
|
||||||
},
|
},
|
||||||
) from exc
|
) from exc
|
||||||
except CookidooRequestException as exc:
|
|
||||||
raise UpdateFailed(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="setup_request_exception",
|
|
||||||
) from exc
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Authentication failed but re-authentication"
|
"Authentication failed but re-authentication"
|
||||||
" was successful, trying again later"
|
" was successful, trying again later"
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import CookieJar
|
|
||||||
from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
|
from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
|
||||||
|
|
||||||
from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
|
from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .coordinator import CookidooConfigEntry
|
from .coordinator import CookidooConfigEntry
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ async def cookidoo_from_config_data(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return Cookidoo(
|
return Cookidoo(
|
||||||
async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)),
|
async_get_clientsession(hass),
|
||||||
CookidooConfig(
|
CookidooConfig(
|
||||||
email=data[CONF_EMAIL],
|
email=data[CONF_EMAIL],
|
||||||
password=data[CONF_PASSWORD],
|
password=data[CONF_PASSWORD],
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["cookidoo_api"],
|
"loggers": ["cookidoo_api"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["cookidoo-api==0.17.2"]
|
"requirements": ["cookidoo-api==0.14.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
|
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "silver",
|
||||||
"requirements": ["data-grand-lyon-ha==0.7.0"]
|
"requirements": ["data-grand-lyon-ha==0.7.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,15 +49,13 @@ rules:
|
|||||||
status: exempt
|
status: exempt
|
||||||
comment: This is a service integration; there are no discoverable devices.
|
comment: This is a service integration; there are no discoverable devices.
|
||||||
docs-data-update: done
|
docs-data-update: done
|
||||||
docs-examples: done
|
docs-examples: todo
|
||||||
docs-known-limitations: done
|
docs-known-limitations: done
|
||||||
docs-supported-devices: done
|
docs-supported-devices: done
|
||||||
docs-supported-functions: done
|
docs-supported-functions: done
|
||||||
docs-troubleshooting: done
|
docs-troubleshooting: done
|
||||||
docs-use-cases: done
|
docs-use-cases: done
|
||||||
dynamic-devices:
|
dynamic-devices: done
|
||||||
status: exempt
|
|
||||||
comment: This is a service integration; devices are added and removed manually by the user.
|
|
||||||
entity-category: done
|
entity-category: done
|
||||||
entity-device-class: done
|
entity-device-class: done
|
||||||
entity-disabled-by-default: done
|
entity-disabled-by-default: done
|
||||||
@@ -68,9 +66,7 @@ rules:
|
|||||||
repair-issues:
|
repair-issues:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: no known use cases for repair issues or flows, yet
|
comment: no known use cases for repair issues or flows, yet
|
||||||
stale-devices:
|
stale-devices: done
|
||||||
status: exempt
|
|
||||||
comment: This is a service integration; devices are added and removed manually by the user.
|
|
||||||
|
|
||||||
# Platinum
|
# Platinum
|
||||||
async-dependency: done
|
async-dependency: done
|
||||||
|
|||||||
@@ -3,14 +3,23 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
|
||||||
from homeassistant.const import STATE_HOME
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import discovery
|
from homeassistant.helpers import discovery
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.util.hass_dict import HassKey
|
|
||||||
|
|
||||||
|
from .config_entry import ( # noqa: F401
|
||||||
|
DATA_COMPONENT,
|
||||||
|
BaseScannerEntity,
|
||||||
|
BaseTrackerEntity,
|
||||||
|
ScannerEntity,
|
||||||
|
ScannerEntityDescription,
|
||||||
|
TrackerEntity,
|
||||||
|
TrackerEntityDescription,
|
||||||
|
async_setup_entry,
|
||||||
|
async_unload_entry,
|
||||||
|
)
|
||||||
from .const import ( # noqa: F401
|
from .const import ( # noqa: F401
|
||||||
ATTR_ATTRIBUTES,
|
ATTR_ATTRIBUTES,
|
||||||
ATTR_BATTERY,
|
ATTR_BATTERY,
|
||||||
@@ -36,14 +45,6 @@ from .const import ( # noqa: F401
|
|||||||
SCAN_INTERVAL,
|
SCAN_INTERVAL,
|
||||||
SourceType,
|
SourceType,
|
||||||
)
|
)
|
||||||
from .entity import ( # noqa: F401
|
|
||||||
BaseScannerEntity,
|
|
||||||
BaseTrackerEntity,
|
|
||||||
ScannerEntity,
|
|
||||||
ScannerEntityDescription,
|
|
||||||
TrackerEntity,
|
|
||||||
TrackerEntityDescription,
|
|
||||||
)
|
|
||||||
from .legacy import ( # noqa: F401
|
from .legacy import ( # noqa: F401
|
||||||
PLATFORM_SCHEMA,
|
PLATFORM_SCHEMA,
|
||||||
PLATFORM_SCHEMA_BASE,
|
PLATFORM_SCHEMA_BASE,
|
||||||
@@ -59,8 +60,6 @@ from .legacy import ( # noqa: F401
|
|||||||
see,
|
see,
|
||||||
)
|
)
|
||||||
|
|
||||||
DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN)
|
|
||||||
|
|
||||||
|
|
||||||
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
|
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
|
||||||
"""Return the state if any or a specified device is home."""
|
"""Return the state if any or a specified device is home."""
|
||||||
@@ -109,23 +108,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
eager_start=True,
|
eager_start=True,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
||||||
"""Set up an entry."""
|
|
||||||
component: EntityComponent[BaseTrackerEntity] | None = hass.data.get(DOMAIN)
|
|
||||||
|
|
||||||
if component is not None:
|
|
||||||
return await component.async_setup_entry(entry)
|
|
||||||
|
|
||||||
component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity](
|
|
||||||
LOGGER, DOMAIN, hass
|
|
||||||
)
|
|
||||||
component.register_shutdown()
|
|
||||||
|
|
||||||
return await component.async_setup_entry(entry)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
||||||
"""Unload an entry."""
|
|
||||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
|
||||||
|
|||||||
@@ -1,45 +1,472 @@
|
|||||||
"""Code to set up a device tracker platform using a config entry."""
|
"""Code to set up a device tracker platform using a config entry."""
|
||||||
|
|
||||||
from functools import partial
|
import asyncio
|
||||||
|
from typing import Any, final
|
||||||
|
|
||||||
from homeassistant.helpers.deprecation import (
|
from propcache.api import cached_property
|
||||||
DeprecatedAlias,
|
|
||||||
all_with_deprecated_constants,
|
from homeassistant.components import zone
|
||||||
check_if_deprecated_constant,
|
from homeassistant.config_entries import ConfigEntry
|
||||||
dir_with_deprecated_constants,
|
from homeassistant.const import (
|
||||||
|
ATTR_BATTERY_LEVEL,
|
||||||
|
ATTR_GPS_ACCURACY,
|
||||||
|
ATTR_LATITUDE,
|
||||||
|
ATTR_LONGITUDE,
|
||||||
|
STATE_HOME,
|
||||||
|
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.helpers.device_registry import (
|
||||||
|
DeviceInfo,
|
||||||
|
EventDeviceRegistryUpdatedData,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||||
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||||
|
from homeassistant.helpers.typing import StateType
|
||||||
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ATTR_HOST_NAME,
|
||||||
|
ATTR_IN_ZONES,
|
||||||
|
ATTR_IP,
|
||||||
|
ATTR_MAC,
|
||||||
|
ATTR_SOURCE_TYPE,
|
||||||
|
CONNECTED_DEVICE_REGISTERED,
|
||||||
|
DOMAIN,
|
||||||
|
LOGGER,
|
||||||
|
SourceType,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import (
|
DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN)
|
||||||
BaseTrackerEntity as _BaseTrackerEntity,
|
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
|
||||||
ScannerEntity as _ScannerEntity,
|
|
||||||
SourceType as _SourceType,
|
|
||||||
TrackerEntity as _TrackerEntity,
|
|
||||||
TrackerEntityDescription as _TrackerEntityDescription,
|
|
||||||
)
|
|
||||||
|
|
||||||
_DEPRECATED_TrackerEntity = DeprecatedAlias(
|
# mypy: disallow-any-generics
|
||||||
_TrackerEntity, "homeassistant.components.device_tracker.TrackerEntity", "2027.6"
|
|
||||||
)
|
|
||||||
_DEPRECATED_ScannerEntity = DeprecatedAlias(
|
|
||||||
_ScannerEntity, "homeassistant.components.device_tracker.ScannerEntity", "2027.6"
|
|
||||||
)
|
|
||||||
_DEPRECATED_BaseTrackerEntity = DeprecatedAlias(
|
|
||||||
_BaseTrackerEntity,
|
|
||||||
"homeassistant.components.device_tracker.BaseTrackerEntity",
|
|
||||||
"2027.6",
|
|
||||||
)
|
|
||||||
_DEPRECATED_TrackerEntityDescription = DeprecatedAlias(
|
|
||||||
_TrackerEntityDescription,
|
|
||||||
"homeassistant.components.device_tracker.TrackerEntityDescription",
|
|
||||||
"2027.6",
|
|
||||||
)
|
|
||||||
_DEPRECATED_SourceType = DeprecatedAlias(
|
|
||||||
_SourceType, "homeassistant.components.device_tracker.SourceType", "2027.6"
|
|
||||||
)
|
|
||||||
|
|
||||||
# These can be removed if no deprecated aliases are in this module anymore
|
|
||||||
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
__dir__ = partial(
|
"""Set up an entry."""
|
||||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
component: EntityComponent[BaseTrackerEntity] | None = hass.data.get(DOMAIN)
|
||||||
)
|
|
||||||
__all__ = all_with_deprecated_constants(globals())
|
if component is not None:
|
||||||
|
return await component.async_setup_entry(entry)
|
||||||
|
|
||||||
|
component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity](
|
||||||
|
LOGGER, DOMAIN, hass
|
||||||
|
)
|
||||||
|
component.register_shutdown()
|
||||||
|
|
||||||
|
return await component.async_setup_entry(entry)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload an entry."""
|
||||||
|
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_connected_device_registered(
|
||||||
|
hass: HomeAssistant, mac: str, ip_address: str | None, hostname: str | None
|
||||||
|
) -> None:
|
||||||
|
"""Register a newly seen connected device.
|
||||||
|
|
||||||
|
This is currently used by the dhcp integration
|
||||||
|
to listen for newly registered connected devices
|
||||||
|
for discovery.
|
||||||
|
"""
|
||||||
|
async_dispatcher_send(
|
||||||
|
hass,
|
||||||
|
CONNECTED_DEVICE_REGISTERED,
|
||||||
|
{
|
||||||
|
ATTR_IP: ip_address,
|
||||||
|
ATTR_MAC: mac,
|
||||||
|
ATTR_HOST_NAME: hostname,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_register_mac(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
domain: str,
|
||||||
|
mac: str,
|
||||||
|
unique_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Register a mac address with a unique ID."""
|
||||||
|
mac = dr.format_mac(mac)
|
||||||
|
if DATA_KEY in hass.data:
|
||||||
|
hass.data[DATA_KEY][mac] = (domain, unique_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Setup listening.
|
||||||
|
|
||||||
|
# dict mapping mac -> partial unique ID
|
||||||
|
data = hass.data[DATA_KEY] = {mac: (domain, unique_id)}
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None:
|
||||||
|
"""Enable the online status entity for the mac of a newly created device."""
|
||||||
|
# Only for new devices
|
||||||
|
if ev.data["action"] != "create":
|
||||||
|
return
|
||||||
|
|
||||||
|
dev_reg = dr.async_get(hass)
|
||||||
|
device_entry = dev_reg.async_get(ev.data["device_id"])
|
||||||
|
|
||||||
|
if device_entry is None:
|
||||||
|
# This should not happen, since the device was just created.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if device has a mac
|
||||||
|
mac = None
|
||||||
|
for conn in device_entry.connections:
|
||||||
|
if conn[0] == dr.CONNECTION_NETWORK_MAC:
|
||||||
|
mac = conn[1]
|
||||||
|
break
|
||||||
|
|
||||||
|
if mac is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if we have an entity for this mac
|
||||||
|
if (unique_id := data.get(mac)) is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
ent_reg = er.async_get(hass)
|
||||||
|
|
||||||
|
if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
entity_entry = ent_reg.entities[entity_id]
|
||||||
|
|
||||||
|
# Make sure entity has a config entry and was disabled by the
|
||||||
|
# default disable logic in the integration and new entities
|
||||||
|
# are allowed to be added.
|
||||||
|
if (
|
||||||
|
entity_entry.config_entry_id is None
|
||||||
|
or (
|
||||||
|
(
|
||||||
|
config_entry := hass.config_entries.async_get_entry(
|
||||||
|
entity_entry.config_entry_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
and config_entry.pref_disable_new_entities
|
||||||
|
)
|
||||||
|
or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Enable entity
|
||||||
|
ent_reg.async_update_entity(entity_id, disabled_by=None)
|
||||||
|
|
||||||
|
hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTrackerEntity(Entity):
|
||||||
|
"""Represent a tracked device.
|
||||||
|
|
||||||
|
Not intended to be directly inherited by integrations. Integrations should
|
||||||
|
inherit TrackerEntity, BaseScannerEntity or ScannerEntity instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_attr_device_info: None = None
|
||||||
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
_attr_source_type: SourceType
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def battery_level(self) -> int | None:
|
||||||
|
"""Return the battery level of the device.
|
||||||
|
|
||||||
|
Percentage from 0-100.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_type(self) -> SourceType:
|
||||||
|
"""Return the source type, eg gps or router, of the device."""
|
||||||
|
if hasattr(self, "_attr_source_type"):
|
||||||
|
return self._attr_source_type
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_attributes(self) -> dict[str, StateType]:
|
||||||
|
"""Return the device state attributes."""
|
||||||
|
attr: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type}
|
||||||
|
|
||||||
|
if self.battery_level is not None:
|
||||||
|
attr[ATTR_BATTERY_LEVEL] = self.battery_level
|
||||||
|
|
||||||
|
return attr
|
||||||
|
|
||||||
|
|
||||||
|
class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||||
|
"""A class that describes tracker entities."""
|
||||||
|
|
||||||
|
|
||||||
|
CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
|
||||||
|
"latitude",
|
||||||
|
"location_accuracy",
|
||||||
|
"location_name",
|
||||||
|
"longitude",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TrackerEntity(
|
||||||
|
BaseTrackerEntity, cached_properties=CACHED_TRACKER_PROPERTIES_WITH_ATTR_
|
||||||
|
):
|
||||||
|
"""Base class for a tracked device."""
|
||||||
|
|
||||||
|
entity_description: TrackerEntityDescription
|
||||||
|
_attr_latitude: float | None = None
|
||||||
|
_attr_location_accuracy: float = 0
|
||||||
|
_attr_location_name: str | None = None
|
||||||
|
_attr_longitude: float | None = None
|
||||||
|
_attr_source_type: SourceType = SourceType.GPS
|
||||||
|
|
||||||
|
__active_zone: State | None = None
|
||||||
|
__in_zones: list[str] | None = None
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def should_poll(self) -> bool:
|
||||||
|
"""No polling for entities that have location pushed."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def force_update(self) -> bool:
|
||||||
|
"""All updates need to be written to the state machine if we're not polling."""
|
||||||
|
return not self.should_poll
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def location_accuracy(self) -> float:
|
||||||
|
"""Return the location accuracy of the device.
|
||||||
|
|
||||||
|
Value in meters.
|
||||||
|
"""
|
||||||
|
return self._attr_location_accuracy
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def location_name(self) -> str | None:
|
||||||
|
"""Return a location name for the current location of the device."""
|
||||||
|
return self._attr_location_name
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def latitude(self) -> float | None:
|
||||||
|
"""Return latitude value of the device."""
|
||||||
|
return self._attr_latitude
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def longitude(self) -> float | None:
|
||||||
|
"""Return longitude value of the device."""
|
||||||
|
return self._attr_longitude
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.__active_zone = None
|
||||||
|
self.__in_zones = None
|
||||||
|
super()._async_write_ha_state()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> str | None:
|
||||||
|
"""Return the state of the device."""
|
||||||
|
if self.location_name is not None:
|
||||||
|
return self.location_name
|
||||||
|
|
||||||
|
if self.latitude is not None and self.longitude is not None:
|
||||||
|
zone_state = self.__active_zone
|
||||||
|
if zone_state is None:
|
||||||
|
state = STATE_NOT_HOME
|
||||||
|
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
|
||||||
|
state = STATE_HOME
|
||||||
|
else:
|
||||||
|
state = zone_state.name
|
||||||
|
return state
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@final
|
||||||
|
@property
|
||||||
|
def state_attributes(self) -> dict[str, Any]:
|
||||||
|
"""Return the device state attributes."""
|
||||||
|
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
|
||||||
|
attr.update(super().state_attributes)
|
||||||
|
|
||||||
|
if self.latitude is not None and self.longitude is not None:
|
||||||
|
attr[ATTR_IN_ZONES] = self.__in_zones or []
|
||||||
|
attr[ATTR_LATITUDE] = self.latitude
|
||||||
|
attr[ATTR_LONGITUDE] = self.longitude
|
||||||
|
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
|
||||||
|
|
||||||
|
return attr
|
||||||
|
|
||||||
|
|
||||||
|
class BaseScannerEntity(BaseTrackerEntity):
|
||||||
|
"""Base class for a tracked device that can be connected or disconnected.
|
||||||
|
|
||||||
|
Unlike ScannerEntity, this entity does not make assumptions about MAC
|
||||||
|
addresses being used to identify the device.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> str | None:
|
||||||
|
"""Return the state of the device."""
|
||||||
|
if self.is_connected is None:
|
||||||
|
return None
|
||||||
|
if self.is_connected:
|
||||||
|
return STATE_HOME
|
||||||
|
return STATE_NOT_HOME
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool | None:
|
||||||
|
"""Return true if the device is connected."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||||
|
"""A class that describes tracker entities."""
|
||||||
|
|
||||||
|
|
||||||
|
CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = {
|
||||||
|
"ip_address",
|
||||||
|
"mac_address",
|
||||||
|
"hostname",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ScannerEntity(
|
||||||
|
BaseScannerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
|
||||||
|
):
|
||||||
|
"""Base class for a tracked device that is on a scanned network."""
|
||||||
|
|
||||||
|
entity_description: ScannerEntityDescription
|
||||||
|
_attr_hostname: str | None = None
|
||||||
|
_attr_ip_address: str | None = None
|
||||||
|
_attr_mac_address: str | None = None
|
||||||
|
_attr_source_type: SourceType = SourceType.ROUTER
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def ip_address(self) -> str | None:
|
||||||
|
"""Return the primary ip address of the device."""
|
||||||
|
return self._attr_ip_address
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def mac_address(self) -> str | None:
|
||||||
|
"""Return the mac address of the device."""
|
||||||
|
return self._attr_mac_address
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def hostname(self) -> str | None:
|
||||||
|
"""Return hostname of the device."""
|
||||||
|
return self._attr_hostname
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self) -> str | None:
|
||||||
|
"""Return unique ID of the entity."""
|
||||||
|
return self.mac_address
|
||||||
|
|
||||||
|
@final
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo | None:
|
||||||
|
"""Device tracker entities should not create device registry entries."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def entity_registry_enabled_default(self) -> bool:
|
||||||
|
"""Return if entity is enabled by default."""
|
||||||
|
# If mac_address is None, we can never find a device entry.
|
||||||
|
return (
|
||||||
|
# Do not disable if we won't activate our attach to device logic
|
||||||
|
self.mac_address is None
|
||||||
|
or self.device_info is not None
|
||||||
|
# Disable if we automatically attach but there is no device
|
||||||
|
or self.find_device_entry() is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def add_to_platform_start(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
platform: EntityPlatform,
|
||||||
|
parallel_updates: asyncio.Semaphore | None,
|
||||||
|
) -> None:
|
||||||
|
"""Start adding an entity to a platform."""
|
||||||
|
super().add_to_platform_start(hass, platform, parallel_updates)
|
||||||
|
if self.mac_address and self.unique_id:
|
||||||
|
_async_register_mac(
|
||||||
|
hass,
|
||||||
|
platform.platform_name,
|
||||||
|
self.mac_address,
|
||||||
|
self.unique_id,
|
||||||
|
)
|
||||||
|
if self.is_connected and self.ip_address:
|
||||||
|
_async_connected_device_registered(
|
||||||
|
hass,
|
||||||
|
self.mac_address,
|
||||||
|
self.ip_address,
|
||||||
|
self.hostname,
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def find_device_entry(self) -> dr.DeviceEntry | None:
|
||||||
|
"""Return device entry."""
|
||||||
|
assert self.mac_address is not None
|
||||||
|
|
||||||
|
return dr.async_get(self.hass).async_get_device(
|
||||||
|
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_internal_added_to_hass(self) -> None:
|
||||||
|
"""Handle added to Home Assistant."""
|
||||||
|
# Entities without a unique ID don't have a device
|
||||||
|
if (
|
||||||
|
not self.registry_entry
|
||||||
|
or not self.platform.config_entry
|
||||||
|
or not self.mac_address
|
||||||
|
or (device_entry := self.find_device_entry()) is None
|
||||||
|
# Entities should not have a device info. We opt them out
|
||||||
|
# of this logic if they do.
|
||||||
|
or self.device_info
|
||||||
|
):
|
||||||
|
if self.device_info:
|
||||||
|
LOGGER.debug("Entity %s unexpectedly has a device info", self.entity_id)
|
||||||
|
await super().async_internal_added_to_hass()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Attach entry to device
|
||||||
|
if self.registry_entry.device_id != device_entry.id:
|
||||||
|
self.registry_entry = er.async_get(self.hass).async_update_entity(
|
||||||
|
self.entity_id, device_id=device_entry.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach device to config entry
|
||||||
|
if self.platform.config_entry.entry_id not in device_entry.config_entries:
|
||||||
|
dr.async_get(self.hass).async_update_device(
|
||||||
|
device_entry.id,
|
||||||
|
add_config_entry_id=self.platform.config_entry.entry_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Do this last or else the entity registry update listener has been installed
|
||||||
|
await super().async_internal_added_to_hass()
|
||||||
|
|
||||||
|
@final
|
||||||
|
@property
|
||||||
|
def state_attributes(self) -> dict[str, StateType]:
|
||||||
|
"""Return the device state attributes."""
|
||||||
|
attr = super().state_attributes
|
||||||
|
|
||||||
|
if ip_address := self.ip_address:
|
||||||
|
attr[ATTR_IP] = ip_address
|
||||||
|
if (mac_address := self.mac_address) is not None:
|
||||||
|
attr[ATTR_MAC] = mac_address
|
||||||
|
if (hostname := self.hostname) is not None:
|
||||||
|
attr[ATTR_HOST_NAME] = hostname
|
||||||
|
|
||||||
|
return attr
|
||||||
|
|||||||
@@ -1,494 +0,0 @@
|
|||||||
"""Provide functionality to keep track of devices."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from typing import Any, final
|
|
||||||
|
|
||||||
from propcache.api import cached_property
|
|
||||||
|
|
||||||
from homeassistant.components import zone
|
|
||||||
from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
|
|
||||||
from homeassistant.const import (
|
|
||||||
ATTR_BATTERY_LEVEL,
|
|
||||||
ATTR_GPS_ACCURACY,
|
|
||||||
ATTR_LATITUDE,
|
|
||||||
ATTR_LONGITUDE,
|
|
||||||
STATE_HOME,
|
|
||||||
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.helpers.device_registry import (
|
|
||||||
DeviceInfo,
|
|
||||||
EventDeviceRegistryUpdatedData,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
|
||||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
|
||||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
|
||||||
from homeassistant.util.hass_dict import HassKey
|
|
||||||
|
|
||||||
from .const import (
|
|
||||||
ATTR_HOST_NAME,
|
|
||||||
ATTR_IN_ZONES,
|
|
||||||
ATTR_IP,
|
|
||||||
ATTR_MAC,
|
|
||||||
ATTR_SOURCE_TYPE,
|
|
||||||
CONNECTED_DEVICE_REGISTERED,
|
|
||||||
DOMAIN,
|
|
||||||
LOGGER,
|
|
||||||
SourceType,
|
|
||||||
)
|
|
||||||
|
|
||||||
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _async_connected_device_registered(
|
|
||||||
hass: HomeAssistant, mac: str, ip_address: str | None, hostname: str | None
|
|
||||||
) -> None:
|
|
||||||
"""Register a newly seen connected device.
|
|
||||||
|
|
||||||
This is currently used by the dhcp integration
|
|
||||||
to listen for newly registered connected devices
|
|
||||||
for discovery.
|
|
||||||
"""
|
|
||||||
async_dispatcher_send(
|
|
||||||
hass,
|
|
||||||
CONNECTED_DEVICE_REGISTERED,
|
|
||||||
{
|
|
||||||
ATTR_IP: ip_address,
|
|
||||||
ATTR_MAC: mac,
|
|
||||||
ATTR_HOST_NAME: hostname,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _async_register_mac(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
domain: str,
|
|
||||||
mac: str,
|
|
||||||
unique_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""Register a mac address with a unique ID."""
|
|
||||||
mac = dr.format_mac(mac)
|
|
||||||
if DATA_KEY in hass.data:
|
|
||||||
hass.data[DATA_KEY][mac] = (domain, unique_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Setup listening.
|
|
||||||
|
|
||||||
# dict mapping mac -> partial unique ID
|
|
||||||
data = hass.data[DATA_KEY] = {mac: (domain, unique_id)}
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None:
|
|
||||||
"""Enable the online status entity for the mac of a newly created device."""
|
|
||||||
# Only for new devices
|
|
||||||
if ev.data["action"] != "create":
|
|
||||||
return
|
|
||||||
|
|
||||||
dev_reg = dr.async_get(hass)
|
|
||||||
device_entry = dev_reg.async_get(ev.data["device_id"])
|
|
||||||
|
|
||||||
if device_entry is None:
|
|
||||||
# This should not happen, since the device was just created.
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if device has a mac
|
|
||||||
mac = None
|
|
||||||
for conn in device_entry.connections:
|
|
||||||
if conn[0] == dr.CONNECTION_NETWORK_MAC:
|
|
||||||
mac = conn[1]
|
|
||||||
break
|
|
||||||
|
|
||||||
if mac is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if we have an entity for this mac
|
|
||||||
if (unique_id := data.get(mac)) is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
ent_reg = er.async_get(hass)
|
|
||||||
|
|
||||||
if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
entity_entry = ent_reg.entities[entity_id]
|
|
||||||
|
|
||||||
# Make sure entity has a config entry and was disabled by the
|
|
||||||
# default disable logic in the integration and new entities
|
|
||||||
# are allowed to be added.
|
|
||||||
if (
|
|
||||||
entity_entry.config_entry_id is None
|
|
||||||
or (
|
|
||||||
(
|
|
||||||
config_entry := hass.config_entries.async_get_entry(
|
|
||||||
entity_entry.config_entry_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
is not None
|
|
||||||
and config_entry.pref_disable_new_entities
|
|
||||||
)
|
|
||||||
or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION
|
|
||||||
):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Enable entity
|
|
||||||
ent_reg.async_update_entity(entity_id, disabled_by=None)
|
|
||||||
|
|
||||||
hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event)
|
|
||||||
|
|
||||||
|
|
||||||
class BaseTrackerEntity(Entity):
|
|
||||||
"""Represent a tracked device.
|
|
||||||
|
|
||||||
Not intended to be directly inherited by integrations. Integrations should
|
|
||||||
inherit TrackerEntity, BaseScannerEntity or ScannerEntity instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_attr_device_info: None = None
|
|
||||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
|
||||||
_attr_source_type: SourceType
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def battery_level(self) -> int | None:
|
|
||||||
"""Return the battery level of the device.
|
|
||||||
|
|
||||||
Percentage from 0-100.
|
|
||||||
"""
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source_type(self) -> SourceType:
|
|
||||||
"""Return the source type, eg gps or router, of the device."""
|
|
||||||
if hasattr(self, "_attr_source_type"):
|
|
||||||
return self._attr_source_type
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state_attributes(self) -> dict[str, Any]:
|
|
||||||
"""Return the device state attributes."""
|
|
||||||
attr: dict[str, Any] = {ATTR_SOURCE_TYPE: self.source_type}
|
|
||||||
|
|
||||||
if self.battery_level is not None:
|
|
||||||
attr[ATTR_BATTERY_LEVEL] = self.battery_level
|
|
||||||
|
|
||||||
return attr
|
|
||||||
|
|
||||||
|
|
||||||
class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):
|
|
||||||
"""A class that describes tracker entities."""
|
|
||||||
|
|
||||||
|
|
||||||
CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
|
|
||||||
"in_zones",
|
|
||||||
"latitude",
|
|
||||||
"location_accuracy",
|
|
||||||
"location_name",
|
|
||||||
"longitude",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TrackerEntity(
|
|
||||||
BaseTrackerEntity, cached_properties=CACHED_TRACKER_PROPERTIES_WITH_ATTR_
|
|
||||||
):
|
|
||||||
"""Base class for a tracked device."""
|
|
||||||
|
|
||||||
entity_description: TrackerEntityDescription
|
|
||||||
_attr_in_zones: list[str] | None = None
|
|
||||||
_attr_latitude: float | None = None
|
|
||||||
_attr_location_accuracy: float = 0
|
|
||||||
_attr_location_name: str | None = None
|
|
||||||
_attr_longitude: float | None = None
|
|
||||||
_attr_source_type: SourceType = SourceType.GPS
|
|
||||||
|
|
||||||
__active_zone: State | None = None
|
|
||||||
__in_zones: list[str] | None = None
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def should_poll(self) -> bool:
|
|
||||||
"""No polling for entities that have location pushed."""
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def force_update(self) -> bool:
|
|
||||||
"""All updates need to be written to the state machine if we're not polling."""
|
|
||||||
return not self.should_poll
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def in_zones(self) -> list[str] | None:
|
|
||||||
"""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.
|
|
||||||
"""
|
|
||||||
return self._attr_in_zones
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def location_accuracy(self) -> float:
|
|
||||||
"""Return the location accuracy of the device.
|
|
||||||
|
|
||||||
Value in meters.
|
|
||||||
"""
|
|
||||||
return self._attr_location_accuracy
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def location_name(self) -> str | None:
|
|
||||||
"""Return a location name for the current location of the device."""
|
|
||||||
return self._attr_location_name
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def latitude(self) -> float | None:
|
|
||||||
"""Return latitude value of the device."""
|
|
||||||
return self._attr_latitude
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def longitude(self) -> float | None:
|
|
||||||
"""Return longitude value of the device."""
|
|
||||||
return self._attr_longitude
|
|
||||||
|
|
||||||
@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:
|
|
||||||
zone_states = sorted(
|
|
||||||
(
|
|
||||||
zone_state
|
|
||||||
for entity_id in zones
|
|
||||||
if (zone_state := self.hass.states.get(entity_id)) is not None
|
|
||||||
),
|
|
||||||
key=lambda z: z.attributes[ATTR_RADIUS],
|
|
||||||
)
|
|
||||||
self.__active_zone = next(
|
|
||||||
(z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
self.__in_zones = [z.entity_id for z in zone_states]
|
|
||||||
else:
|
|
||||||
self.__active_zone = None
|
|
||||||
self.__in_zones = None
|
|
||||||
super()._async_write_ha_state()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state(self) -> str | None:
|
|
||||||
"""Return the state of the device."""
|
|
||||||
if self.location_name is not None:
|
|
||||||
return self.location_name
|
|
||||||
|
|
||||||
if (
|
|
||||||
self.latitude is not None and self.longitude is not None
|
|
||||||
) or self.__in_zones is not None:
|
|
||||||
zone_state = self.__active_zone
|
|
||||||
if zone_state is None:
|
|
||||||
state = STATE_NOT_HOME
|
|
||||||
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
|
|
||||||
state = STATE_HOME
|
|
||||||
else:
|
|
||||||
state = zone_state.name
|
|
||||||
return state
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@final
|
|
||||||
@property
|
|
||||||
def state_attributes(self) -> dict[str, Any]:
|
|
||||||
"""Return the device state attributes."""
|
|
||||||
attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []}
|
|
||||||
attr.update(super().state_attributes)
|
|
||||||
|
|
||||||
if self.latitude is not None and self.longitude is not None:
|
|
||||||
attr[ATTR_LATITUDE] = self.latitude
|
|
||||||
attr[ATTR_LONGITUDE] = self.longitude
|
|
||||||
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
|
|
||||||
|
|
||||||
return attr
|
|
||||||
|
|
||||||
|
|
||||||
class BaseScannerEntity(BaseTrackerEntity):
|
|
||||||
"""Base class for a tracked device that can be connected or disconnected.
|
|
||||||
|
|
||||||
Unlike ScannerEntity, this entity does not make assumptions about MAC
|
|
||||||
addresses being used to identify the device.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state(self) -> str | None:
|
|
||||||
"""Return the state of the device."""
|
|
||||||
if self.is_connected is None:
|
|
||||||
return None
|
|
||||||
if self.is_connected:
|
|
||||||
return STATE_HOME
|
|
||||||
return STATE_NOT_HOME
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_connected(self) -> bool | None:
|
|
||||||
"""Return true if the device is connected."""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@final
|
|
||||||
@property
|
|
||||||
def state_attributes(self) -> dict[str, Any]:
|
|
||||||
"""Return the device state attributes."""
|
|
||||||
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
|
|
||||||
attr.update(super().state_attributes)
|
|
||||||
|
|
||||||
if not self.is_connected:
|
|
||||||
return attr
|
|
||||||
|
|
||||||
attr[ATTR_IN_ZONES] = [
|
|
||||||
zone.ENTITY_ID_HOME,
|
|
||||||
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
|
|
||||||
]
|
|
||||||
|
|
||||||
return attr
|
|
||||||
|
|
||||||
|
|
||||||
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
|
|
||||||
"""A class that describes tracker entities."""
|
|
||||||
|
|
||||||
|
|
||||||
CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = {
|
|
||||||
"ip_address",
|
|
||||||
"mac_address",
|
|
||||||
"hostname",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ScannerEntity(
|
|
||||||
BaseScannerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
|
|
||||||
):
|
|
||||||
"""Base class for a tracked device that is on a scanned network."""
|
|
||||||
|
|
||||||
entity_description: ScannerEntityDescription
|
|
||||||
_attr_hostname: str | None = None
|
|
||||||
_attr_ip_address: str | None = None
|
|
||||||
_attr_mac_address: str | None = None
|
|
||||||
_attr_source_type: SourceType = SourceType.ROUTER
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def ip_address(self) -> str | None:
|
|
||||||
"""Return the primary ip address of the device."""
|
|
||||||
return self._attr_ip_address
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def mac_address(self) -> str | None:
|
|
||||||
"""Return the mac address of the device."""
|
|
||||||
return self._attr_mac_address
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def hostname(self) -> str | None:
|
|
||||||
"""Return hostname of the device."""
|
|
||||||
return self._attr_hostname
|
|
||||||
|
|
||||||
@property
|
|
||||||
def unique_id(self) -> str | None:
|
|
||||||
"""Return unique ID of the entity."""
|
|
||||||
return self.mac_address
|
|
||||||
|
|
||||||
@final
|
|
||||||
@property
|
|
||||||
def device_info(self) -> DeviceInfo | None:
|
|
||||||
"""Device tracker entities should not create device registry entries."""
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def entity_registry_enabled_default(self) -> bool:
|
|
||||||
"""Return if entity is enabled by default."""
|
|
||||||
# If mac_address is None, we can never find a device entry.
|
|
||||||
return (
|
|
||||||
# Do not disable if we won't activate our attach to device logic
|
|
||||||
self.mac_address is None
|
|
||||||
or self.device_info is not None
|
|
||||||
# Disable if we automatically attach but there is no device
|
|
||||||
or self.find_device_entry() is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def add_to_platform_start(
|
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
platform: EntityPlatform,
|
|
||||||
parallel_updates: asyncio.Semaphore | None,
|
|
||||||
) -> None:
|
|
||||||
"""Start adding an entity to a platform."""
|
|
||||||
super().add_to_platform_start(hass, platform, parallel_updates)
|
|
||||||
if self.mac_address and self.unique_id:
|
|
||||||
_async_register_mac(
|
|
||||||
hass,
|
|
||||||
platform.platform_name,
|
|
||||||
self.mac_address,
|
|
||||||
self.unique_id,
|
|
||||||
)
|
|
||||||
if self.is_connected and self.ip_address:
|
|
||||||
_async_connected_device_registered(
|
|
||||||
hass,
|
|
||||||
self.mac_address,
|
|
||||||
self.ip_address,
|
|
||||||
self.hostname,
|
|
||||||
)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def find_device_entry(self) -> dr.DeviceEntry | None:
|
|
||||||
"""Return device entry."""
|
|
||||||
assert self.mac_address is not None
|
|
||||||
|
|
||||||
return dr.async_get(self.hass).async_get_device(
|
|
||||||
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_internal_added_to_hass(self) -> None:
|
|
||||||
"""Handle added to Home Assistant."""
|
|
||||||
# Entities without a unique ID don't have a device
|
|
||||||
if (
|
|
||||||
not self.registry_entry
|
|
||||||
or not self.platform.config_entry
|
|
||||||
or not self.mac_address
|
|
||||||
or (device_entry := self.find_device_entry()) is None
|
|
||||||
# Entities should not have a device info. We opt them out
|
|
||||||
# of this logic if they do.
|
|
||||||
or self.device_info
|
|
||||||
):
|
|
||||||
if self.device_info:
|
|
||||||
LOGGER.debug("Entity %s unexpectedly has a device info", self.entity_id)
|
|
||||||
await super().async_internal_added_to_hass()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Attach entry to device
|
|
||||||
if self.registry_entry.device_id != device_entry.id:
|
|
||||||
self.registry_entry = er.async_get(self.hass).async_update_entity(
|
|
||||||
self.entity_id, device_id=device_entry.id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Attach device to config entry
|
|
||||||
if self.platform.config_entry.entry_id not in device_entry.config_entries:
|
|
||||||
dr.async_get(self.hass).async_update_device(
|
|
||||||
device_entry.id,
|
|
||||||
add_config_entry_id=self.platform.config_entry.entry_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Do this last or else the entity registry update listener has been installed
|
|
||||||
await super().async_internal_added_to_hass()
|
|
||||||
|
|
||||||
# BaseScannerEntity.state_attributes is @final to keep external subclasses
|
|
||||||
# from tampering with it; ScannerEntity is an in-tree subclass that
|
|
||||||
# intentionally extends it with ip/mac/hostname.
|
|
||||||
@final # type: ignore[misc]
|
|
||||||
@property
|
|
||||||
def state_attributes(self) -> dict[str, Any]:
|
|
||||||
"""Return the device state attributes."""
|
|
||||||
attr = super().state_attributes
|
|
||||||
|
|
||||||
if ip_address := self.ip_address:
|
|
||||||
attr[ATTR_IP] = ip_address
|
|
||||||
if (mac_address := self.mac_address) is not None:
|
|
||||||
attr[ATTR_MAC] = mac_address
|
|
||||||
if (hostname := self.hostname) is not None:
|
|
||||||
attr[ATTR_HOST_NAME] = hostname
|
|
||||||
|
|
||||||
return attr
|
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
],
|
],
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"aiodhcpwatcher==1.2.7",
|
"aiodhcpwatcher==1.2.1",
|
||||||
"aiodiscover==3.2.4",
|
"aiodiscover==3.2.3",
|
||||||
"cached-ipaddress==1.1.1"
|
"cached-ipaddress==1.0.1"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from dropmqttapi.discovery import DropDiscovery
|
from dropmqttapi.discovery import DropDiscovery
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_DEVICE_ID
|
|
||||||
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
|
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_COMMAND_TOPIC,
|
CONF_COMMAND_TOPIC,
|
||||||
CONF_DATA_TOPIC,
|
CONF_DATA_TOPIC,
|
||||||
CONF_DEVICE_DESC,
|
CONF_DEVICE_DESC,
|
||||||
|
CONF_DEVICE_ID,
|
||||||
CONF_DEVICE_NAME,
|
CONF_DEVICE_NAME,
|
||||||
CONF_DEVICE_OWNER_ID,
|
CONF_DEVICE_OWNER_ID,
|
||||||
CONF_DEVICE_TYPE,
|
CONF_DEVICE_TYPE,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
CONF_COMMAND_TOPIC = "drop_command_topic"
|
CONF_COMMAND_TOPIC = "drop_command_topic"
|
||||||
CONF_DATA_TOPIC = "drop_data_topic"
|
CONF_DATA_TOPIC = "drop_data_topic"
|
||||||
CONF_DEVICE_DESC = "device_desc"
|
CONF_DEVICE_DESC = "device_desc"
|
||||||
|
# pylint: disable-next=home-assistant-duplicate-const
|
||||||
|
CONF_DEVICE_ID = "device_id"
|
||||||
CONF_DEVICE_TYPE = "device_type"
|
CONF_DEVICE_TYPE = "device_type"
|
||||||
CONF_HUB_ID = "drop_hub_id"
|
CONF_HUB_ID = "drop_hub_id"
|
||||||
CONF_DEVICE_NAME = "name"
|
CONF_DEVICE_NAME = "name"
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
|
|||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONCENTRATION_PARTS_PER_MILLION,
|
CONCENTRATION_PARTS_PER_MILLION,
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
TEMPERATURE,
|
|
||||||
EntityCategory,
|
EntityCategory,
|
||||||
UnitOfPressure,
|
UnitOfPressure,
|
||||||
UnitOfTemperature,
|
UnitOfTemperature,
|
||||||
@@ -50,6 +49,8 @@ CURRENT_SYSTEM_PRESSURE = "current_system_pressure"
|
|||||||
HIGH_SYSTEM_PRESSURE = "high_system_pressure"
|
HIGH_SYSTEM_PRESSURE = "high_system_pressure"
|
||||||
LOW_SYSTEM_PRESSURE = "low_system_pressure"
|
LOW_SYSTEM_PRESSURE = "low_system_pressure"
|
||||||
BATTERY = "battery"
|
BATTERY = "battery"
|
||||||
|
# pylint: disable-next=home-assistant-duplicate-const
|
||||||
|
TEMPERATURE = "temperature"
|
||||||
INLET_TDS = "inlet_tds"
|
INLET_TDS = "inlet_tds"
|
||||||
OUTLET_TDS = "outlet_tds"
|
OUTLET_TDS = "outlet_tds"
|
||||||
CARTRIDGE_1_LIFE = "cart1"
|
CARTRIDGE_1_LIFE = "cart1"
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
|||||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .validation import UnsupportedBoardError, async_get_supported_board_info
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,11 +43,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
box_name, _ = await self._validate_input(discovery_info.ip)
|
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:
|
except DucoConnectionError:
|
||||||
return self.async_abort(reason="cannot_connect")
|
return self.async_abort(reason="cannot_connect")
|
||||||
except DucoError:
|
except DucoError:
|
||||||
@@ -67,12 +61,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Handle zeroconf discovery."""
|
"""Handle zeroconf discovery."""
|
||||||
try:
|
try:
|
||||||
box_name, mac = await self._validate_input(discovery_info.host)
|
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:
|
except DucoConnectionError:
|
||||||
return self.async_abort(reason="cannot_connect")
|
return self.async_abort(reason="cannot_connect")
|
||||||
except DucoError:
|
except DucoError:
|
||||||
@@ -114,8 +102,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
try:
|
try:
|
||||||
box_name, mac = await self._validate_input(user_input[CONF_HOST])
|
box_name, mac = await self._validate_input(user_input[CONF_HOST])
|
||||||
except UnsupportedBoardError:
|
|
||||||
errors["base"] = "unsupported_board"
|
|
||||||
except DucoConnectionError:
|
except DucoConnectionError:
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
except DucoError:
|
except DucoError:
|
||||||
@@ -147,8 +133,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
try:
|
try:
|
||||||
box_name, mac = await self._validate_input(user_input[CONF_HOST])
|
box_name, mac = await self._validate_input(user_input[CONF_HOST])
|
||||||
except UnsupportedBoardError:
|
|
||||||
errors["base"] = "unsupported_board"
|
|
||||||
except DucoConnectionError:
|
except DucoConnectionError:
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
except DucoError:
|
except DucoError:
|
||||||
@@ -178,6 +162,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
session=async_get_clientsession(self.hass),
|
session=async_get_clientsession(self.hass),
|
||||||
host=host,
|
host=host,
|
||||||
)
|
)
|
||||||
board_info = await async_get_supported_board_info(client)
|
board_info = await client.async_get_board_info()
|
||||||
lan_info = await client.async_get_lan_info()
|
lan_info = await client.async_get_lan_info()
|
||||||
return board_info.box_name, lan_info.mac
|
return board_info.box_name, lan_info.mac
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ from dataclasses import dataclass
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from duco_connectivity import DucoClient
|
from duco_connectivity import DucoClient
|
||||||
from duco_connectivity.exceptions import (
|
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||||
DucoConnectionError,
|
|
||||||
DucoError,
|
|
||||||
DucoResponseError,
|
|
||||||
)
|
|
||||||
from duco_connectivity.models import BoardInfo, Node
|
from duco_connectivity.models import BoardInfo, Node
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@@ -17,7 +13,6 @@ from homeassistant.exceptions import ConfigEntryError
|
|||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import DOMAIN, SCAN_INTERVAL
|
from .const import DOMAIN, SCAN_INTERVAL
|
||||||
from .validation import UnsupportedBoardError, async_get_supported_board_info
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -57,18 +52,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
|||||||
async def _async_setup(self) -> None:
|
async def _async_setup(self) -> None:
|
||||||
"""Fetch board info once during initial setup."""
|
"""Fetch board info once during initial setup."""
|
||||||
try:
|
try:
|
||||||
self.board_info = await async_get_supported_board_info(self.client)
|
self.board_info = await self.client.async_get_board_info()
|
||||||
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:
|
except DucoConnectionError as err:
|
||||||
raise UpdateFailed(
|
raise UpdateFailed(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
@@ -86,6 +70,20 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
|||||||
"""Fetch node data from the Duco box."""
|
"""Fetch node data from the Duco box."""
|
||||||
try:
|
try:
|
||||||
nodes = await self.client.async_get_nodes()
|
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()
|
lan_info = await self.client.async_get_lan_info()
|
||||||
except DucoConnectionError as err:
|
except DucoConnectionError as err:
|
||||||
raise UpdateFailed(
|
raise UpdateFailed(
|
||||||
|
|||||||
@@ -6,13 +6,11 @@
|
|||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||||
"unique_id_mismatch": "The device you entered belongs to a different Duco box.",
|
"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": {
|
"error": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"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": {
|
"step": {
|
||||||
"discovery_confirm": {
|
"discovery_confirm": {
|
||||||
@@ -100,9 +98,6 @@
|
|||||||
},
|
},
|
||||||
"rate_limit_exceeded": {
|
"rate_limit_exceeded": {
|
||||||
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
|
"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": {
|
"system_health": {
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"""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 = {}
|
self.stations = {}
|
||||||
for station in stations:
|
for station in stations:
|
||||||
label = station["label"]
|
label = station["label"]
|
||||||
rlo_id = station["RLOIid"]
|
rloId = station["RLOIid"]
|
||||||
|
|
||||||
# API annoyingly sometimes returns a list and some times returns a string
|
# API annoyingly sometimes returns a list and some times returns a string
|
||||||
# E.g. L3121 has a label of ['Scurf Dyke', 'Scurf Dyke Dyke Level']
|
# E.g. L3121 has a label of ['Scurf Dyke', 'Scurf Dyke Dyke Level']
|
||||||
@@ -50,11 +50,11 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
# Similar for RLOIid
|
# Similar for RLOIid
|
||||||
# E.g. 0018 has an RLOIid of ['10427', '9154']
|
# E.g. 0018 has an RLOIid of ['10427', '9154']
|
||||||
if isinstance(rlo_id, list):
|
if isinstance(rloId, list):
|
||||||
rlo_id = rlo_id[-1]
|
rloId = rloId[-1]
|
||||||
|
|
||||||
full_name = label + " - " + rlo_id
|
fullName = label + " - " + rloId
|
||||||
self.stations[full_name] = station["stationReference"]
|
self.stations[fullName] = station["stationReference"]
|
||||||
|
|
||||||
if not self.stations:
|
if not self.stations:
|
||||||
return self.async_abort(reason="no_stations")
|
return self.async_abort(reason="no_stations")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user