mirror of
https://github.com/home-assistant/core.git
synced 2026-05-27 03:05:50 +02:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51cddb88f5 | |||
| 0a7293dbbd | |||
| 057788d531 | |||
| 74cb4e2448 | |||
| 62aa79a304 | |||
| da74ae1955 | |||
| 2a4728463b | |||
| 3c5bcad0e9 | |||
| 2388353bd2 | |||
| 98823d6816 | |||
| cdd09f2535 | |||
| 2c900c59eb | |||
| 68757996de | |||
| 0fa3985b1d | |||
| a2551647b8 | |||
| e19601f991 | |||
| bc6060f98b | |||
| 0e2190fb25 | |||
| dd75a39e25 | |||
| 6efb3fffa3 | |||
| 4ef409f3cd | |||
| 0842c1cdfc | |||
| 49c045236c | |||
| 0b687df9f8 | |||
| ffcab49087 | |||
| 06c92cd328 | |||
| 748a9842af | |||
| 55786dbdfc | |||
| e88c03a437 | |||
| dbc0dc1ea6 | |||
| 31271876bf | |||
| d5c31332b5 | |||
| 3f0c93c26c | |||
| 07ed913ba2 | |||
| b7905b163f | |||
| c712b07da3 |
@@ -0,0 +1,52 @@
|
||||
name: Cache and install APT packages
|
||||
description: >-
|
||||
Wraps awalsh128/cache-apt-pkgs-action with the workarounds Home Assistant CI
|
||||
needs. Removes the conflicting Microsoft apt source before any apt run, and
|
||||
points the dynamic linker at the host's multiarch lib subdirectories so
|
||||
shared libraries that rely on update-alternatives or postinst-managed paths
|
||||
(eg libblas, liblapack pulled in by ffmpeg) stay reachable since the upstream
|
||||
action does not execute postinst scripts on cache restore.
|
||||
|
||||
inputs:
|
||||
packages:
|
||||
description: Space-delimited list of apt packages to install.
|
||||
required: true
|
||||
version:
|
||||
description: Cache version. Bump to invalidate the cache.
|
||||
required: false
|
||||
default: "1"
|
||||
execute_install_scripts:
|
||||
description: >-
|
||||
Pass-through to awalsh128/cache-apt-pkgs-action. Postinst scripts are not
|
||||
actually cached by the upstream action, so this is largely a no-op today.
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Remove conflicting Microsoft apt source
|
||||
shell: bash
|
||||
run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
|
||||
- name: Install apt packages via cache
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
|
||||
with:
|
||||
packages: ${{ inputs.packages }}
|
||||
version: ${{ inputs.version }}
|
||||
execute_install_scripts: ${{ inputs.execute_install_scripts }}
|
||||
- name: Refresh dynamic linker cache
|
||||
shell: bash
|
||||
run: |
|
||||
# awalsh128/cache-apt-pkgs-action does not run postinst scripts on
|
||||
# cache restore, so update-alternatives symlinks (eg the one libblas
|
||||
# creates at /usr/lib/<multiarch>/libblas.so.3) are never produced.
|
||||
# Add every /usr/lib/<multiarch> subdirectory that holds shared
|
||||
# libraries to the ldconfig search path so the dynamic linker still
|
||||
# finds them. Use dpkg-architecture to derive the host's multiarch
|
||||
# tuple so this works on non-x86_64 runners too.
|
||||
multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
|
||||
find "/usr/lib/${multiarch}" -mindepth 2 -maxdepth 2 \
|
||||
-name '*.so.*' -printf '%h\n' \
|
||||
| sort -u \
|
||||
| sudo tee /etc/ld.so.conf.d/zzz-cache-apt-extras.conf > /dev/null
|
||||
sudo ldconfig
|
||||
@@ -0,0 +1,42 @@
|
||||
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}"
|
||||
+132
-241
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
CACHE_VERSION: 4
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.6"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
@@ -60,9 +60,7 @@ env:
|
||||
# - 15.2 is the latest (as of 9 Feb 2023)
|
||||
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
|
||||
UV_CACHE_DIR: /tmp/uv-cache
|
||||
APT_CACHE_BASE: /home/runner/work/apt
|
||||
APT_CACHE_DIR: /home/runner/work/apt/cache
|
||||
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
|
||||
APT_CACHE_VERSION: 1
|
||||
SQLALCHEMY_WARN_20: 1
|
||||
PYTHONASYNCIODEBUG: 1
|
||||
HASS_CI: 1
|
||||
@@ -86,12 +84,13 @@ jobs:
|
||||
core: ${{ steps.core.outputs.changes }}
|
||||
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
|
||||
integrations: ${{ steps.integrations.outputs.changes }}
|
||||
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
|
||||
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
|
||||
requirements: ${{ steps.core.outputs.requirements }}
|
||||
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
|
||||
postgresql_groups: ${{ steps.info.outputs.postgresql_groups }}
|
||||
python_versions: ${{ steps.info.outputs.python_versions }}
|
||||
default_python: ${{ steps.info.outputs.default_python }}
|
||||
uv_version: ${{ steps.info.outputs.uv_version }}
|
||||
test_full_suite: ${{ steps.info.outputs.test_full_suite }}
|
||||
test_group_count: ${{ steps.info.outputs.test_group_count }}
|
||||
test_groups: ${{ steps.info.outputs.test_groups }}
|
||||
@@ -116,10 +115,6 @@ jobs:
|
||||
# Include HA_SHORT_VERSION to force the immediate creation
|
||||
# of a new uv cache entry after a version bump.
|
||||
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
|
||||
- name: Generate partial apt restore key
|
||||
id: generate_apt_cache_key
|
||||
run: |
|
||||
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
|
||||
- name: Filter for core changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: core
|
||||
@@ -242,6 +237,11 @@ jobs:
|
||||
echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT
|
||||
echo "python_versions: ${all_python_versions}"
|
||||
echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT
|
||||
echo "default_python: ${default_python}"
|
||||
echo "default_python=${default_python}" >> $GITHUB_OUTPUT
|
||||
uv_version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)
|
||||
echo "uv_version: ${uv_version}"
|
||||
echo "uv_version=${uv_version}" >> $GITHUB_OUTPUT
|
||||
echo "test_full_suite: ${test_full_suite}"
|
||||
echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT
|
||||
echo "integrations_glob: ${integrations_glob}"
|
||||
@@ -351,12 +351,12 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
- name: Set up uv and Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -384,80 +384,40 @@ jobs:
|
||||
path: ${{ env.UV_CACHE_DIR }}
|
||||
key: ${{ steps.generate-uv-key.outputs.full_key }}
|
||||
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
|
||||
- name: Check if apt cache exists
|
||||
id: cache-apt-check
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
if: |
|
||||
steps.cache-venv.outputs.cache-hit != 'true'
|
||||
|| steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
id: install-os-deps
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
|
||||
mkdir -p ${APT_CACHE_DIR}
|
||||
mkdir -p ${APT_LIST_CACHE_DIR}
|
||||
fi
|
||||
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils \
|
||||
libavcodec-dev \
|
||||
libavdevice-dev \
|
||||
libavfilter-dev \
|
||||
libavformat-dev \
|
||||
libavutil-dev \
|
||||
libswresample-dev \
|
||||
libswscale-dev \
|
||||
libudev-dev
|
||||
|
||||
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
|
||||
sudo chmod -R 755 ${APT_CACHE_BASE}
|
||||
fi
|
||||
- name: Save apt cache
|
||||
if: |
|
||||
always()
|
||||
&& steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
&& steps.install-os-deps.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
libavcodec-dev
|
||||
libavdevice-dev
|
||||
libavfilter-dev
|
||||
libavformat-dev
|
||||
libavutil-dev
|
||||
libswresample-dev
|
||||
libswscale-dev
|
||||
libudev-dev
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
id: create-venv
|
||||
env:
|
||||
PYTHON_VERSION: ${{ steps.python.outputs.python-version }}
|
||||
run: |
|
||||
python -m venv venv
|
||||
uv venv venv --python "${PYTHON_VERSION}"
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pip install "$(grep '^uv' < requirements.txt)"
|
||||
uv pip install -U "pip>=25.2"
|
||||
uv pip install -r requirements.txt
|
||||
uv pip install -r requirements_all.txt -r requirements_test.txt
|
||||
uv pip install -e . --config-settings editable_mode=compat
|
||||
- name: Dump pip freeze
|
||||
run: |
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
uv pip freeze >> pip_freeze.txt
|
||||
@@ -506,36 +466,22 @@ jobs:
|
||||
&& github.event.inputs.mypy-only != 'true'
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
libturbojpeg
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: libturbojpeg
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -569,10 +515,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -605,10 +551,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Run gen_copilot_instructions.py
|
||||
run: |
|
||||
python -m script.gen_copilot_instructions validate
|
||||
@@ -660,10 +606,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -711,10 +657,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -764,10 +710,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -815,10 +761,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Generate partial mypy restore key
|
||||
id: generate-mypy-key
|
||||
run: |
|
||||
@@ -876,38 +822,26 @@ jobs:
|
||||
- info
|
||||
- base
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -952,39 +886,27 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -1105,40 +1027,28 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libmariadb-dev-compat \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libmariadb-dev-compat
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -1266,42 +1176,35 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
sudo apt-get -y install \
|
||||
postgresql-server-dev-14
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up PostgreSQL apt repository
|
||||
run: sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
- name: Cache PostgreSQL development headers
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: postgresql-server-dev-14
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -1449,39 +1352,27 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
exclude_types: [csv, json, html]
|
||||
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.25.2
|
||||
rev: v1.24.1
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
|
||||
@@ -337,6 +337,7 @@ homeassistant.components.led_ble.*
|
||||
homeassistant.components.lektrico.*
|
||||
homeassistant.components.letpot.*
|
||||
homeassistant.components.lg_infrared.*
|
||||
homeassistant.components.lg_tv_rs232.*
|
||||
homeassistant.components.libre_hardware_monitor.*
|
||||
homeassistant.components.lidarr.*
|
||||
homeassistant.components.liebherr.*
|
||||
|
||||
Generated
+4
@@ -987,6 +987,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/tests/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/homeassistant/components/lg_tv_rs232/ @balloob
|
||||
/tests/components/lg_tv_rs232/ @balloob
|
||||
/homeassistant/components/libre_hardware_monitor/ @Sab44
|
||||
/tests/components/libre_hardware_monitor/ @Sab44
|
||||
/homeassistant/components/lichess/ @aryanhasgithub
|
||||
@@ -1290,6 +1292,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/openhome/ @bazwilliams
|
||||
/homeassistant/components/openrgb/ @felipecrs
|
||||
/tests/components/openrgb/ @felipecrs
|
||||
/homeassistant/components/opensensemap/ @AlCalzone
|
||||
/tests/components/opensensemap/ @AlCalzone
|
||||
/homeassistant/components/opensky/ @joostlek
|
||||
/tests/components/opensky/ @joostlek
|
||||
/homeassistant/components/opentherm_gw/ @mvn23
|
||||
|
||||
@@ -17,6 +17,7 @@ PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.EVENT,
|
||||
Platform.MEDIA_PLAYER,
|
||||
Platform.NOTIFY,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
@@ -40,6 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
await coordinator.sync_history_state()
|
||||
await coordinator.sync_media_state()
|
||||
|
||||
async def _on_http2_reauth_required() -> None:
|
||||
entry.async_start_reauth(hass)
|
||||
|
||||
@@ -8,7 +8,12 @@ from aioamazondevices.exceptions import (
|
||||
CannotConnect,
|
||||
CannotRetrieveData,
|
||||
)
|
||||
from aioamazondevices.structures import AmazonDevice, AmazonVocalRecord
|
||||
from aioamazondevices.structures import (
|
||||
AmazonDevice,
|
||||
AmazonMediaState,
|
||||
AmazonVocalRecord,
|
||||
AmazonVolumeState,
|
||||
)
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -74,10 +79,17 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
}
|
||||
|
||||
self._vocal_records: dict[str, AmazonVocalRecord] = {}
|
||||
|
||||
self.api.on_history_event.append(self.history_state_event_handler)
|
||||
self.api.on_history_event.freeze()
|
||||
|
||||
self._volume_states: dict[str, AmazonVolumeState] = {}
|
||||
self.api.on_volume_state_event.append(self.volume_state_event_handler)
|
||||
self.api.on_volume_state_event.freeze()
|
||||
|
||||
self._media_states: dict[str, AmazonMediaState] = {}
|
||||
self.api.on_media_state_event.append(self.media_state_event_handler)
|
||||
self.api.on_media_state_event.freeze()
|
||||
|
||||
async def _async_update_data(self) -> dict[str, AmazonDevice]:
|
||||
"""Update device data."""
|
||||
try:
|
||||
@@ -189,3 +201,31 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
def vocal_records(self) -> dict[str, AmazonVocalRecord]:
|
||||
"""Vocal records of devices."""
|
||||
return self._vocal_records
|
||||
|
||||
async def sync_media_state(self) -> None:
|
||||
"""Sync media state."""
|
||||
await self.api.sync_media_state()
|
||||
|
||||
async def media_state_event_handler(
|
||||
self, media_state: dict[str, AmazonMediaState]
|
||||
) -> None:
|
||||
"""Handle pushed media state changed events."""
|
||||
self._media_states = media_state
|
||||
self.async_update_listeners()
|
||||
|
||||
@property
|
||||
def media_states(self) -> dict[str, AmazonMediaState]:
|
||||
"""Media state of devices."""
|
||||
return self._media_states
|
||||
|
||||
async def volume_state_event_handler(
|
||||
self, volume_states: dict[str, AmazonVolumeState]
|
||||
) -> None:
|
||||
"""Handle pushed volume change events."""
|
||||
self._volume_states = volume_states
|
||||
self.async_update_listeners()
|
||||
|
||||
@property
|
||||
def volume_states(self) -> dict[str, AmazonVolumeState]:
|
||||
"""Volumes of devices."""
|
||||
return self._volume_states
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Media player platform for Alexa Devices."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Final
|
||||
|
||||
from aioamazondevices.structures import (
|
||||
AmazonMediaControls,
|
||||
AmazonMediaState,
|
||||
AmazonVolumeState,
|
||||
)
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEnqueue,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityDescription,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import _LOGGER
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .entity import AmazonEntity
|
||||
from .utils import alexa_api_call
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
STANDARD_SUPPORTED_FEATURES = (
|
||||
MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
|
||||
"""Describes an Alexa Devices media player entity."""
|
||||
|
||||
|
||||
MEDIA_PLAYERS: Final = (
|
||||
AmazonDevicesMediaPlayerEntityDescription(
|
||||
key="media",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Alexa Devices media player entities from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
"""Add entities for newly discovered devices."""
|
||||
new_entities: list[AlexaDevicesMediaPlayer] = []
|
||||
|
||||
for serial_num, device in coordinator.data.items():
|
||||
if serial_num in known_devices or not device.media_player_supported:
|
||||
continue
|
||||
|
||||
known_devices.add(serial_num)
|
||||
new_entities.extend(
|
||||
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
|
||||
for description in MEDIA_PLAYERS
|
||||
)
|
||||
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
remove_listener = coordinator.async_add_listener(_check_device)
|
||||
entry.async_on_unload(remove_listener)
|
||||
_check_device()
|
||||
|
||||
|
||||
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
"""Representation of an Alexa device media player."""
|
||||
|
||||
entity_description: AmazonDevicesMediaPlayerEntityDescription
|
||||
|
||||
_attr_name = None # Uses the device name
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_volume_step = 0.05
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
serial_num: str,
|
||||
description: AmazonDevicesMediaPlayerEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self._prev_volume: int | None = None
|
||||
super().__init__(coordinator, serial_num, description)
|
||||
|
||||
@property
|
||||
def media_state(self) -> AmazonMediaState | None:
|
||||
"""Return the media state relating to device."""
|
||||
if not self.coordinator or not self.coordinator.media_states:
|
||||
return None
|
||||
return self.coordinator.media_states.get(self._serial_num)
|
||||
|
||||
@property
|
||||
def volume_state(self) -> AmazonVolumeState | None:
|
||||
"""Volume settings for device."""
|
||||
if not self.coordinator or not self.coordinator.volume_states:
|
||||
return None
|
||||
return self.coordinator.volume_states.get(self._serial_num)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
"""Return dynamically supported features based on current media."""
|
||||
features = STANDARD_SUPPORTED_FEATURES
|
||||
|
||||
if self.media_state is None:
|
||||
return features
|
||||
|
||||
if self.media_state.pause_enabled:
|
||||
features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE
|
||||
|
||||
if self.media_state.next_enabled:
|
||||
features |= MediaPlayerEntityFeature.NEXT_TRACK
|
||||
|
||||
if self.media_state.previous_enabled:
|
||||
features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
|
||||
return features
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState | None:
|
||||
"""Return the current state of the player."""
|
||||
if not self.media_state:
|
||||
return MediaPlayerState.IDLE
|
||||
if self.media_state.player_state == "PLAYING":
|
||||
return MediaPlayerState.PLAYING
|
||||
if self.media_state.player_state == "PAUSED":
|
||||
return MediaPlayerState.PAUSED
|
||||
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
"""Return the volume level (0.0 to 1.0)."""
|
||||
if not self.volume_state or self.volume_state.volume is None:
|
||||
return None
|
||||
return self.volume_state.volume / 100
|
||||
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return True if the volume is muted."""
|
||||
if not self.volume_state:
|
||||
return None
|
||||
return self.volume_state.volume == 0
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
"""Track title."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.now_playing_title
|
||||
|
||||
@property
|
||||
def media_artist(self) -> str | None:
|
||||
"""Artist name."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.now_playing_line1
|
||||
|
||||
@property
|
||||
def media_album_name(self) -> str | None:
|
||||
"""Album name."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.now_playing_line2
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Album art URL."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.now_playing_url
|
||||
|
||||
@property
|
||||
def media_duration(self) -> int | None:
|
||||
"""Duration in seconds."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.media_length
|
||||
|
||||
@property
|
||||
def media_position(self) -> int | None:
|
||||
"""Current playback position in seconds."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.media_position
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self) -> datetime | None:
|
||||
"""When media_position was last updated — HA uses this to interpolate the progress bar."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.media_position_updated_at
|
||||
|
||||
@property
|
||||
def media_content_type(self) -> MediaType | None:
|
||||
"""Content type — tells HA what kind of media is playing."""
|
||||
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
|
||||
return MediaType.MUSIC
|
||||
return None
|
||||
|
||||
async def async_play_media(
|
||||
self,
|
||||
media_type: MediaType | str,
|
||||
media_id: str,
|
||||
enqueue: MediaPlayerEnqueue | None = None,
|
||||
announce: bool | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Play a piece of media."""
|
||||
await self.async_call_alexa_music(media_id, media_type)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_call_alexa_music(
|
||||
self, search_phrase: str, provider_id: str
|
||||
) -> None:
|
||||
"""Call alexa music."""
|
||||
await self.coordinator.api.call_alexa_music(
|
||||
self.device, search_phrase, provider_id
|
||||
)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_set_device_volume(self, volume: int) -> None:
|
||||
"""Set the device volume."""
|
||||
_LOGGER.debug(
|
||||
"Setting volume for %s to %s%%",
|
||||
self.device.serial_number,
|
||||
volume,
|
||||
)
|
||||
await self.coordinator.api.set_device_volume(self.device, volume)
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set the volume level (0.0 to 1.0)."""
|
||||
device_volume = round(volume * 100)
|
||||
await self.async_set_device_volume(device_volume)
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute or un-mute the volume."""
|
||||
# Whilst you can mute a device by asking it there appears to be
|
||||
# no way to do this programmatically so set volume to 0
|
||||
if not self.volume_state or self.volume_state.volume is None:
|
||||
return
|
||||
if mute:
|
||||
self._prev_volume = self.volume_state.volume
|
||||
target_volume = 0
|
||||
else:
|
||||
if self._prev_volume is None:
|
||||
return
|
||||
target_volume = self._prev_volume
|
||||
await self.async_set_volume_level(target_volume / 100)
|
||||
|
||||
@alexa_api_call
|
||||
async def _send_media_command(self, command: AmazonMediaControls) -> None:
|
||||
_LOGGER.debug(
|
||||
"Sending media command '%s' to %s", command, self.device.serial_number
|
||||
)
|
||||
await self.coordinator.api.send_media_command(self.device, command)
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
await self._send_media_command(AmazonMediaControls.Stop)
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
await self._send_media_command(AmazonMediaControls.Pause)
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
await self._send_media_command(AmazonMediaControls.Play)
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
await self._send_media_command(AmazonMediaControls.Next)
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
await self._send_media_command(AmazonMediaControls.Previous)
|
||||
@@ -230,13 +230,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 3:
|
||||
# Remove Temperature parameter
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
temperature_key = "temperature"
|
||||
|
||||
for subentry in entry.subentries.values():
|
||||
data = subentry.data.copy()
|
||||
if CONF_TEMPERATURE not in data:
|
||||
if temperature_key not in data:
|
||||
continue
|
||||
data.pop(CONF_TEMPERATURE, None)
|
||||
data.pop(temperature_key, None)
|
||||
hass.config_entries.async_update_subentry(entry, subentry, data=data)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=4)
|
||||
|
||||
@@ -7,27 +7,3 @@ CONNECTION_TIMEOUT: int = 10
|
||||
|
||||
# Field name of last self test retrieved from apcupsd.
|
||||
LAST_S_TEST: Final = "laststest"
|
||||
|
||||
# Mapping of deprecated sensor keys (as reported by apcupsd,
|
||||
# lower-cased) to their deprecation
|
||||
# repair issue translation keys.
|
||||
DEPRECATED_SENSORS: Final = {
|
||||
"apc": "apc_deprecated",
|
||||
"end apc": "date_deprecated",
|
||||
"date": "date_deprecated",
|
||||
"apcmodel": "available_via_device_info",
|
||||
"model": "available_via_device_info",
|
||||
"firmware": "available_via_device_info",
|
||||
"version": "available_via_device_info",
|
||||
"upsname": "available_via_device_info",
|
||||
"serialno": "available_via_device_info",
|
||||
}
|
||||
|
||||
AVAILABLE_VIA_DEVICE_ATTR: Final = {
|
||||
"apcmodel": "model",
|
||||
"model": "model",
|
||||
"firmware": "hw_version",
|
||||
"version": "sw_version",
|
||||
"upsname": "name",
|
||||
"serialno": "serial_number",
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Support for APCUPSd sensors."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
import dateutil
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -24,11 +23,9 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
import homeassistant.helpers.issue_registry as ir
|
||||
|
||||
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST
|
||||
from .const import LAST_S_TEST
|
||||
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
|
||||
from .entity import APCUPSdEntity
|
||||
|
||||
@@ -36,6 +33,20 @@ PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# List of useless sensors to ignore, since they are either provided in device
|
||||
# information, or not useful at all
|
||||
IGNORED_SENSORS: Final = {
|
||||
"apc",
|
||||
"end apc",
|
||||
"date",
|
||||
"apcmodel",
|
||||
"model",
|
||||
"firmware",
|
||||
"version",
|
||||
"upsname",
|
||||
"serialno",
|
||||
}
|
||||
|
||||
SENSORS: dict[str, SensorEntityDescription] = {
|
||||
"alarmdel": SensorEntityDescription(
|
||||
key="alarmdel",
|
||||
@@ -49,18 +60,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"apc": SensorEntityDescription(
|
||||
key="apc",
|
||||
translation_key="apc_status",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"apcmodel": SensorEntityDescription(
|
||||
key="apcmodel",
|
||||
translation_key="apc_model",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"badbatts": SensorEntityDescription(
|
||||
key="badbatts",
|
||||
translation_key="bad_batteries",
|
||||
@@ -100,12 +99,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
),
|
||||
"date": SensorEntityDescription(
|
||||
key="date",
|
||||
translation_key="date",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"dipsw": SensorEntityDescription(
|
||||
key="dipsw",
|
||||
translation_key="dip_switch_settings",
|
||||
@@ -132,23 +125,11 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="wake_delay",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"end apc": SensorEntityDescription(
|
||||
key="end apc",
|
||||
translation_key="date_and_time",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"extbatts": SensorEntityDescription(
|
||||
key="extbatts",
|
||||
translation_key="external_batteries",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"firmware": SensorEntityDescription(
|
||||
key="firmware",
|
||||
translation_key="firmware_version",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"hitrans": SensorEntityDescription(
|
||||
key="hitrans",
|
||||
translation_key="transfer_high",
|
||||
@@ -264,12 +245,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="min_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"model": SensorEntityDescription(
|
||||
key="model",
|
||||
translation_key="model",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"nombattv": SensorEntityDescription(
|
||||
key="nombattv",
|
||||
translation_key="battery_nominal_voltage",
|
||||
@@ -358,12 +333,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"serialno": SensorEntityDescription(
|
||||
key="serialno",
|
||||
translation_key="serial_number",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"starttime": SensorEntityDescription(
|
||||
key="starttime",
|
||||
translation_key="startup_time",
|
||||
@@ -404,18 +373,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="ups_mode",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"upsname": SensorEntityDescription(
|
||||
key="upsname",
|
||||
translation_key="ups_name",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"version": SensorEntityDescription(
|
||||
key="version",
|
||||
translation_key="version",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"xoffbat": SensorEntityDescription(
|
||||
key="xoffbat",
|
||||
translation_key="transfer_from_battery",
|
||||
@@ -481,9 +438,10 @@ async def async_setup_entry(
|
||||
# as unknown initially.
|
||||
#
|
||||
# We also sort the resources to ensure the order of entities
|
||||
# created is deterministic since "APCMODEL" and "MODEL"
|
||||
# resources map to the same "Model" name.
|
||||
# created is deterministic
|
||||
for resource in sorted(available_resources | {LAST_S_TEST}):
|
||||
if resource in IGNORED_SENSORS:
|
||||
continue
|
||||
if resource not in SENSORS:
|
||||
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
||||
continue
|
||||
@@ -561,63 +519,3 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
self._attr_native_value, inferred_unit = infer_unit(data)
|
||||
if not self.native_unit_of_measurement:
|
||||
self._attr_native_unit_of_measurement = inferred_unit
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle when entity is added to Home Assistant.
|
||||
|
||||
If this is a deprecated sensor entity, create a repair issue to guide
|
||||
the user to disable it.
|
||||
"""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
reason = DEPRECATED_SENSORS.get(self.entity_description.key)
|
||||
if not reason:
|
||||
return
|
||||
|
||||
automations = automations_with_entity(self.hass, self.entity_id)
|
||||
scripts = scripts_with_entity(self.hass, self.entity_id)
|
||||
if not automations and not scripts:
|
||||
return
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
items = [
|
||||
f"- [{entry.name or entry.original_name or entity_id}]"
|
||||
f"(/config/{integration}/edit/"
|
||||
f"{entry.unique_id or entity_id.split('.', 1)[-1]})"
|
||||
for integration, entities in (
|
||||
("automation", automations),
|
||||
("script", scripts),
|
||||
)
|
||||
for entity_id in entities
|
||||
if (entry := entity_registry.async_get(entity_id))
|
||||
]
|
||||
placeholders = {
|
||||
"entity_name": str(self.name or self.entity_id),
|
||||
"entity_id": self.entity_id,
|
||||
"items": "\n".join(items),
|
||||
}
|
||||
if via_attr := AVAILABLE_VIA_DEVICE_ATTR.get(self.entity_description.key):
|
||||
placeholders["available_via_device_attr"] = via_attr
|
||||
if device_entry := self.device_entry:
|
||||
placeholders["device_id"] = device_entry.id
|
||||
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"{reason}_{self.entity_id}",
|
||||
breaks_in_ha_version="2026.6.0",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=reason,
|
||||
translation_placeholders=placeholders,
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Handle when entity will be removed from Home Assistant."""
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
if issue_key := DEPRECATED_SENSORS.get(self.entity_description.key):
|
||||
ir.async_delete_issue(self.hass, DOMAIN, f"{issue_key}_{self.entity_id}")
|
||||
|
||||
@@ -241,19 +241,5 @@
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to APC UPS Daemon."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"apc_deprecated": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because it exposes internal details of the APC UPS Daemon response.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use supported APC UPS entities instead. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
},
|
||||
"available_via_device_info": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the same value is available from the device registry via `device_attr(\"{device_id}\", \"{available_via_device_attr}\")`.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use the `device_attr` helper instead of this sensor. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
},
|
||||
"date_deprecated": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the timestamp is already available from other APC UPS sensors via their last updated time.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to reference any entity's `last_updated` attribute instead (for example, `states.binary_sensor.apcups_online_status.last_updated`). Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,20 @@ SENSORS_TYPE_COUNT = "sensors_count"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_ENTITY_MIGRATION_ID = {
|
||||
"sensor_connected_device": "Devices Connected",
|
||||
"sensor_rx_bytes": "Download",
|
||||
"sensor_tx_bytes": "Upload",
|
||||
"sensor_rx_rates": "Download Speed",
|
||||
"sensor_tx_rates": "Upload Speed",
|
||||
"sensor_load_avg1": "Load Avg (1m)",
|
||||
"sensor_load_avg5": "Load Avg (5m)",
|
||||
"sensor_load_avg15": "Load Avg (15m)",
|
||||
"2.4GHz": "2.4GHz Temperature",
|
||||
"5.0GHz": "5GHz Temperature",
|
||||
"CPU": "CPU Temperature",
|
||||
}
|
||||
|
||||
|
||||
class AsusWrtSensorDataHandler:
|
||||
"""Data handler for AsusWrt sensor."""
|
||||
@@ -187,20 +201,6 @@ class AsusWrtRouter:
|
||||
|
||||
def _migrate_entities_unique_id(self) -> None:
|
||||
"""Migrate router entities to new unique id format."""
|
||||
_ENTITY_MIGRATION_ID = {
|
||||
"sensor_connected_device": "Devices Connected",
|
||||
"sensor_rx_bytes": "Download",
|
||||
"sensor_tx_bytes": "Upload",
|
||||
"sensor_rx_rates": "Download Speed",
|
||||
"sensor_tx_rates": "Upload Speed",
|
||||
"sensor_load_avg1": "Load Avg (1m)",
|
||||
"sensor_load_avg5": "Load Avg (5m)",
|
||||
"sensor_load_avg15": "Load Avg (15m)",
|
||||
"2.4GHz": "2.4GHz Temperature",
|
||||
"5.0GHz": "5GHz Temperature",
|
||||
"CPU": "CPU Temperature",
|
||||
}
|
||||
|
||||
entity_reg = er.async_get(self.hass)
|
||||
router_entries = er.async_entries_for_config_entry(
|
||||
entity_reg, self._entry.entry_id
|
||||
|
||||
@@ -32,8 +32,16 @@ OPTIONS_SCHEMA = KNOWN_HOSTS_SCHEMA.extend(
|
||||
vol.Required(CONF_MORE_OPTIONS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_UUID): str,
|
||||
vol.Optional(CONF_IGNORE_CEC): str,
|
||||
vol.Optional(CONF_UUID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
custom_value=True, options=[], multiple=True
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_IGNORE_CEC): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
custom_value=True, options=[], multiple=True
|
||||
),
|
||||
),
|
||||
}
|
||||
),
|
||||
SectionConfig(collapsed=True),
|
||||
@@ -109,13 +117,11 @@ class CastOptionsFlowHandler(OptionsFlow):
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the Google Cast options."""
|
||||
if user_input is not None:
|
||||
ignore_cec = _string_to_list(
|
||||
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, "")
|
||||
ignore_cec = _trim_items(
|
||||
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, [])
|
||||
)
|
||||
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
|
||||
wanted_uuid = _string_to_list(
|
||||
user_input[CONF_MORE_OPTIONS].get(CONF_UUID, "")
|
||||
)
|
||||
wanted_uuid = _trim_items(user_input[CONF_MORE_OPTIONS].get(CONF_UUID, []))
|
||||
updated_config = dict(self.config_entry.data)
|
||||
updated_config[CONF_IGNORE_CEC] = ignore_cec
|
||||
updated_config[CONF_KNOWN_HOSTS] = known_hosts
|
||||
@@ -132,9 +138,7 @@ class CastOptionsFlowHandler(OptionsFlow):
|
||||
for key in (CONF_UUID, CONF_IGNORE_CEC):
|
||||
if key not in self.config_entry.data:
|
||||
continue
|
||||
suggested[CONF_MORE_OPTIONS][key] = _list_to_string(
|
||||
self.config_entry.data[key]
|
||||
)
|
||||
suggested[CONF_MORE_OPTIONS][key] = self.config_entry.data[key]
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
@@ -143,16 +147,5 @@ class CastOptionsFlowHandler(OptionsFlow):
|
||||
)
|
||||
|
||||
|
||||
def _list_to_string(items: list[str]) -> str:
|
||||
comma_separated_string = ""
|
||||
if items:
|
||||
comma_separated_string = ",".join(items)
|
||||
return comma_separated_string
|
||||
|
||||
|
||||
def _string_to_list(string: str) -> list[str]:
|
||||
return [x.strip() for x in string.split(",") if x.strip()]
|
||||
|
||||
|
||||
def _trim_items(items: list[str]) -> list[str]:
|
||||
return [x.strip() for x in items if x.strip()]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Diagnostics for the cert_expiry integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import CertExpiryConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_HOST, "name", "title", "unique_id"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
_hass: HomeAssistant,
|
||||
entry: CertExpiryConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
entry_diagnostics = entry.as_dict()
|
||||
|
||||
coordinator = getattr(entry, "runtime_data", None)
|
||||
|
||||
coordinator_diagnostics: dict[str, Any] = {
|
||||
"host": None,
|
||||
"port": None,
|
||||
"name": None,
|
||||
"expiry_datetime": None,
|
||||
"is_cert_valid": None,
|
||||
"cert_error": None,
|
||||
"last_update_success": None,
|
||||
}
|
||||
|
||||
if coordinator is not None:
|
||||
expiry = coordinator.data.isoformat() if coordinator.data else None
|
||||
cert_error = (
|
||||
(
|
||||
f"{type(coordinator.cert_error).__module__}."
|
||||
f"{type(coordinator.cert_error).__qualname__}"
|
||||
)
|
||||
if coordinator.cert_error
|
||||
else None
|
||||
)
|
||||
|
||||
coordinator_diagnostics = {
|
||||
"host": coordinator.host,
|
||||
"port": coordinator.port,
|
||||
"name": coordinator.name,
|
||||
"expiry_datetime": expiry,
|
||||
"is_cert_valid": coordinator.is_cert_valid,
|
||||
"cert_error": cert_error,
|
||||
"last_update_success": coordinator.last_update_success,
|
||||
}
|
||||
|
||||
return {
|
||||
"entry": async_redact_data(entry_diagnostics, TO_REDACT),
|
||||
"coordinator": async_redact_data(coordinator_diagnostics, TO_REDACT),
|
||||
}
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["data-grand-lyon-ha==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -49,13 +49,15 @@ rules:
|
||||
status: exempt
|
||||
comment: This is a service integration; there are no discoverable devices.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: This is a service integration; devices are added and removed manually by the user.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
@@ -66,7 +68,9 @@ rules:
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: no known use cases for repair issues or flows, yet
|
||||
stale-devices: done
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: This is a service integration; devices are added and removed manually by the user.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
@@ -41,7 +41,7 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self.stations = {}
|
||||
for station in stations:
|
||||
label = station["label"]
|
||||
rloId = station["RLOIid"]
|
||||
rlo_id = station["RLOIid"]
|
||||
|
||||
# API annoyingly sometimes returns a list and some times returns a string
|
||||
# E.g. L3121 has a label of ['Scurf Dyke', 'Scurf Dyke Dyke Level']
|
||||
@@ -50,11 +50,11 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# Similar for RLOIid
|
||||
# E.g. 0018 has an RLOIid of ['10427', '9154']
|
||||
if isinstance(rloId, list):
|
||||
rloId = rloId[-1]
|
||||
if isinstance(rlo_id, list):
|
||||
rlo_id = rlo_id[-1]
|
||||
|
||||
fullName = label + " - " + rloId
|
||||
self.stations[fullName] = station["stationReference"]
|
||||
full_name = label + " - " + rlo_id
|
||||
self.stations[full_name] = station["stationReference"]
|
||||
|
||||
if not self.stations:
|
||||
return self.async_abort(reason="no_stations")
|
||||
|
||||
@@ -40,6 +40,7 @@ ELK_ELEMENTS = {
|
||||
EVENT_ELKM1_KEYPAD_KEY_PRESSED = "elkm1.keypad_key_pressed"
|
||||
|
||||
|
||||
ATTR_DURATION = "duration"
|
||||
ATTR_KEYPAD_ID = "keypad_id"
|
||||
ATTR_KEY = "key"
|
||||
ATTR_KEY_NAME = "key_name"
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
},
|
||||
"speak_word": {
|
||||
"service": "mdi:message-minus"
|
||||
},
|
||||
"switch_output_turn_on_for": {
|
||||
"service": "mdi:timer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,3 +161,15 @@ sensor_zone_trigger:
|
||||
entity:
|
||||
integration: elkm1
|
||||
domain: sensor
|
||||
|
||||
switch_output_turn_on_for:
|
||||
target:
|
||||
entity:
|
||||
integration: elkm1
|
||||
domain: switch
|
||||
fields:
|
||||
duration:
|
||||
example: 42
|
||||
required: true
|
||||
selector:
|
||||
duration:
|
||||
|
||||
@@ -210,6 +210,16 @@
|
||||
}
|
||||
},
|
||||
"name": "Speak word"
|
||||
},
|
||||
"switch_output_turn_on_for": {
|
||||
"description": "Turns on an output for a specified length of time.",
|
||||
"fields": {
|
||||
"duration": {
|
||||
"description": "Length of time to turn the output on for.",
|
||||
"name": "Duration"
|
||||
}
|
||||
},
|
||||
"name": "Switch output turn on for"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Support for control of ElkM1 outputs (relays)."""
|
||||
|
||||
from datetime import timedelta
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from elkm1_lib.const import ThermostatMode, ThermostatSetting
|
||||
@@ -7,15 +9,29 @@ from elkm1_lib.elements import Element
|
||||
from elkm1_lib.elk import Elk
|
||||
from elkm1_lib.outputs import Output
|
||||
from elkm1_lib.thermostats import Thermostat
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
from . import ElkM1ConfigEntry
|
||||
from .const import ATTR_DURATION, DOMAIN
|
||||
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
|
||||
from .models import ELKM1Data
|
||||
|
||||
SERVICE_SWITCH_OUTPUT_TURN_ON_FOR = "switch_output_turn_on_for"
|
||||
|
||||
ELK_OUTPUT_TURN_ON_FOR_SERVICE_SCHEMA: VolDictType = {
|
||||
vol.Required(ATTR_DURATION): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(seconds=1), max=timedelta(seconds=65535)),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -32,6 +48,15 @@ async def async_setup_entry(
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SWITCH_OUTPUT_TURN_ON_FOR,
|
||||
entity_domain=SWITCH_DOMAIN,
|
||||
schema=ELK_OUTPUT_TURN_ON_FOR_SERVICE_SCHEMA,
|
||||
func="async_switch_output_turn_on_for",
|
||||
)
|
||||
|
||||
|
||||
class ElkOutput(ElkAttachedEntity, SwitchEntity):
|
||||
"""Elk output as switch."""
|
||||
@@ -51,6 +76,10 @@ class ElkOutput(ElkAttachedEntity, SwitchEntity):
|
||||
"""Turn off the output."""
|
||||
self._element.turn_off()
|
||||
|
||||
async def async_switch_output_turn_on_for(self, duration: timedelta) -> None:
|
||||
"""Turn on an output for specified length of time."""
|
||||
self._element.turn_on(ceil(duration.total_seconds()))
|
||||
|
||||
|
||||
class ElkThermostatEMHeat(ElkEntity, SwitchEntity):
|
||||
"""Elk Thermostat emergency heat as switch."""
|
||||
@@ -79,3 +108,7 @@ class ElkThermostatEMHeat(ElkEntity, SwitchEntity):
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the output."""
|
||||
self._elk_set(ThermostatMode.EMERGENCY_HEAT)
|
||||
|
||||
async def async_switch_output_turn_on_for(self, duration: timedelta) -> None:
|
||||
"""Turn on an output for specified length of time: not supported for thermostat."""
|
||||
raise HomeAssistantError("supported only on ElkM1 output switch entities")
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==45.2.2",
|
||||
"aioesphomeapi==45.3.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.9.1"
|
||||
],
|
||||
|
||||
@@ -124,11 +124,11 @@ async def async_setup_entry(
|
||||
|
||||
for camera in coordinator.data:
|
||||
device_category = coordinator.data[camera].get("device_category")
|
||||
supportExt = coordinator.data[camera].get("supportExt")
|
||||
support_ext = coordinator.data[camera].get("supportExt")
|
||||
if (
|
||||
device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value
|
||||
and supportExt
|
||||
and str(SupportExt.SupportBatteryManage.value) in supportExt
|
||||
and support_ext
|
||||
and str(SupportExt.SupportBatteryManage.value) in support_ext
|
||||
):
|
||||
entities.append(
|
||||
EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE)
|
||||
|
||||
@@ -7,19 +7,30 @@ from google_air_quality_api.auth import Auth
|
||||
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_REFERRER
|
||||
from .const import CONF_REFERRER, DOMAIN
|
||||
from .coordinator import (
|
||||
GoogleAirQualityConfigEntry,
|
||||
GoogleAirQualityRuntimeData,
|
||||
GoogleAirQualityUpdateCoordinator,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Google Air Quality integration."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GoogleAirQualityConfigEntry
|
||||
|
||||
@@ -11,5 +11,10 @@
|
||||
"default": "mdi:molecule"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_forecast": {
|
||||
"service": "mdi:clock-end"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Services for the Google Air Quality integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Final, cast
|
||||
|
||||
from google_air_quality_api.exceptions import GoogleAirQualityApiError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_DEVICE_ID
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr, selector
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GoogleAirQualityConfigEntry
|
||||
|
||||
ATTR_HOURS: Final = "hours"
|
||||
|
||||
FORECAST_HOURS_MAX: Final = 96
|
||||
|
||||
SERVICE_GET_FORECAST: Final = "get_forecast"
|
||||
|
||||
SERVICE_GET_FORECAST_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector({"integration": DOMAIN}),
|
||||
vol.Required(ATTR_HOURS): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1, max=FORECAST_HOURS_MAX)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _get_config_entry_and_subentry_id(
|
||||
hass: HomeAssistant, device_id: str
|
||||
) -> tuple[GoogleAirQualityConfigEntry, str]:
|
||||
"""Get the config entry and subentry from a selected location device."""
|
||||
device = dr.async_get(hass).async_get(device_id)
|
||||
if device is not None:
|
||||
for entry_id, subentry_ids in device.config_entries_subentries.items():
|
||||
config_entry: ConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
entry_id
|
||||
)
|
||||
if config_entry is None or config_entry.domain != DOMAIN:
|
||||
continue
|
||||
|
||||
gaq_config_entry = cast(GoogleAirQualityConfigEntry, config_entry)
|
||||
for subentry_id in subentry_ids:
|
||||
if (
|
||||
subentry_id is not None
|
||||
and subentry_id
|
||||
in gaq_config_entry.runtime_data.subentries_runtime_data
|
||||
):
|
||||
return gaq_config_entry, subentry_id
|
||||
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
)
|
||||
|
||||
|
||||
async def _async_get_forecast(call: ServiceCall) -> ServiceResponse:
|
||||
"""Fetch the air quality forecast for a configured location."""
|
||||
config_entry, subentry_id = _get_config_entry_and_subentry_id(
|
||||
call.hass, call.data[ATTR_DEVICE_ID]
|
||||
)
|
||||
|
||||
coordinator = config_entry.runtime_data.subentries_runtime_data[subentry_id]
|
||||
|
||||
try:
|
||||
forecast = await config_entry.runtime_data.api.async_get_forecast(
|
||||
coordinator.lat,
|
||||
coordinator.long,
|
||||
timedelta(hours=call.data[ATTR_HOURS]),
|
||||
)
|
||||
except GoogleAirQualityApiError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unable_to_fetch",
|
||||
) from err
|
||||
|
||||
return cast(
|
||||
ServiceResponse,
|
||||
{
|
||||
"forecast_time": forecast.hourly_forecasts[0].date_time,
|
||||
"indexes": forecast.hourly_forecasts[0].indexes,
|
||||
"pollutants": forecast.hourly_forecasts[0].pollutants,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_FORECAST,
|
||||
_async_get_forecast,
|
||||
schema=SERVICE_GET_FORECAST_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
get_forecast:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: google_air_quality
|
||||
hours:
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 96
|
||||
step: 1
|
||||
mode: box
|
||||
@@ -270,8 +270,27 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "Location not found."
|
||||
},
|
||||
"unable_to_fetch": {
|
||||
"message": "[%key:component::google_air_quality::common::unable_to_fetch%]"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_forecast": {
|
||||
"description": "Get an air quality forecast for a configured location.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "The location to fetch the forecast for.",
|
||||
"name": "Location"
|
||||
},
|
||||
"hours": {
|
||||
"description": "How many hours into the future to forecast.",
|
||||
"name": "Hours"
|
||||
}
|
||||
},
|
||||
"name": "Get forecast"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,13 +117,13 @@ class DriveClient:
|
||||
"""Get storage quota of the current user."""
|
||||
res = await self._api.get_user(params={"fields": "storageQuota"})
|
||||
|
||||
storageQuota = res["storageQuota"]
|
||||
limit = storageQuota.get("limit")
|
||||
storage_quota = res["storageQuota"]
|
||||
limit = storage_quota.get("limit")
|
||||
return StorageQuotaData(
|
||||
limit=int(limit) if limit is not None else None,
|
||||
usage=int(storageQuota.get("usage", 0)),
|
||||
usage_in_drive=int(storageQuota.get("usageInDrive", 0)),
|
||||
usage_in_trash=int(storageQuota.get("usageInTrash", 0)),
|
||||
usage=int(storage_quota.get("usage", 0)),
|
||||
usage_in_drive=int(storage_quota.get("usageInDrive", 0)),
|
||||
usage_in_trash=int(storage_quota.get("usageInTrash", 0)),
|
||||
)
|
||||
|
||||
async def async_create_ha_root_folder_if_not_exists(self) -> tuple[str, str]:
|
||||
|
||||
@@ -580,17 +580,17 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
|
||||
if tool_results:
|
||||
messages.append(_create_google_tool_response_content(tool_results))
|
||||
generateContentConfig = self.create_generate_content_config()
|
||||
generateContentConfig.tools = tools or None
|
||||
generateContentConfig.system_instruction = (
|
||||
generate_content_config = self.create_generate_content_config()
|
||||
generate_content_config.tools = tools or None
|
||||
generate_content_config.system_instruction = (
|
||||
prompt if supports_system_instruction else None
|
||||
)
|
||||
generateContentConfig.automatic_function_calling = (
|
||||
generate_content_config.automatic_function_calling = (
|
||||
AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None)
|
||||
)
|
||||
if structure:
|
||||
generateContentConfig.response_mime_type = "application/json"
|
||||
generateContentConfig.response_schema = _format_schema(
|
||||
generate_content_config.response_mime_type = "application/json"
|
||||
generate_content_config.response_schema = _format_schema(
|
||||
convert(
|
||||
structure,
|
||||
custom_serializer=(
|
||||
@@ -608,7 +608,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
*messages,
|
||||
]
|
||||
chat = self._genai_client.aio.chats.create(
|
||||
model=model_name, history=messages, config=generateContentConfig
|
||||
model=model_name, history=messages, config=generate_content_config
|
||||
)
|
||||
user_message = chat_log.content[-1]
|
||||
assert isinstance(user_message, conversation.UserContent)
|
||||
|
||||
@@ -313,7 +313,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse:
|
||||
)
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
FUNC_MAP = {
|
||||
func_map = {
|
||||
SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest,
|
||||
SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest,
|
||||
SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest,
|
||||
@@ -322,7 +322,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse:
|
||||
SERVICE_START_QUEST: coordinator.habitica.start_quest,
|
||||
}
|
||||
|
||||
func = FUNC_MAP[call.service]
|
||||
func = func_map[call.service]
|
||||
|
||||
try:
|
||||
response = await func()
|
||||
|
||||
@@ -30,6 +30,11 @@ OPEN_CLOSE_ATTRIBUTES = [
|
||||
AttributeType.UP_DOWN,
|
||||
]
|
||||
POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION]
|
||||
COVER_DEVICE_PROFILES = {
|
||||
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
|
||||
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
|
||||
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
|
||||
}
|
||||
|
||||
|
||||
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
|
||||
@@ -69,12 +74,6 @@ def get_cover_features(
|
||||
|
||||
def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
|
||||
"""Determine the device class a homee node based on the node profile."""
|
||||
COVER_DEVICE_PROFILES = {
|
||||
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
|
||||
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
|
||||
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
|
||||
}
|
||||
|
||||
return COVER_DEVICE_PROFILES.get(node.profile)
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
||||
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
|
||||
IndevoltSystem.BYPASS_POWER,
|
||||
IndevoltSystem.BYPASS_INPUT_ENERGY,
|
||||
IndevoltBattery.RATED_CAPACITY,
|
||||
IndevoltBattery.DAILY_CHARGING_ENERGY,
|
||||
IndevoltBattery.DAILY_DISCHARGING_ENERGY,
|
||||
IndevoltBattery.TOTAL_CHARGING_ENERGY,
|
||||
@@ -78,7 +79,7 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
||||
IndevoltSolar.DC_INPUT_POWER_2,
|
||||
IndevoltSolar.DC_INPUT_POWER_3,
|
||||
IndevoltSolar.DC_INPUT_POWER_4,
|
||||
IndevoltBattery.RATED_CAPACITY_GEN2,
|
||||
IndevoltBattery.RATED_CAPACITY,
|
||||
IndevoltSystem.BYPASS_POWER,
|
||||
IndevoltSystem.TOTAL_OUTPUT_ENERGY,
|
||||
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
|
||||
@@ -134,6 +135,12 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
||||
IndevoltBattery.PACK_3_CURRENT,
|
||||
IndevoltBattery.PACK_4_CURRENT,
|
||||
IndevoltBattery.PACK_5_CURRENT,
|
||||
IndevoltBattery.MAIN_CYCLES,
|
||||
IndevoltBattery.PACK_1_CYCLES,
|
||||
IndevoltBattery.PACK_2_CYCLES,
|
||||
IndevoltBattery.PACK_3_CYCLES,
|
||||
IndevoltBattery.PACK_4_CYCLES,
|
||||
IndevoltBattery.PACK_5_CYCLES,
|
||||
IndevoltConfig.READ_BYPASS,
|
||||
IndevoltConfig.READ_GRID_CHARGING,
|
||||
IndevoltConfig.READ_LIGHT,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Home Assistant integration for Indevolt device."""
|
||||
|
||||
from datetime import timedelta
|
||||
import itertools
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
|
||||
@@ -29,6 +30,7 @@ from .const import (
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
SCAN_BATCH_SIZE: Final = 50
|
||||
SCAN_INTERVAL: Final = 30
|
||||
|
||||
type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator]
|
||||
@@ -86,10 +88,13 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch raw JSON data from the device."""
|
||||
data: dict[str, Any] = {}
|
||||
sensor_keys = SENSOR_KEYS[self.generation]
|
||||
|
||||
try:
|
||||
return await self.api.fetch_data(sensor_keys)
|
||||
for chunk in itertools.batched(sensor_keys, SCAN_BATCH_SIZE, strict=False):
|
||||
data.update(await self.api.fetch_data(list(chunk)))
|
||||
|
||||
except (ClientError, OSError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -97,6 +102,9 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
else:
|
||||
return data
|
||||
|
||||
async def async_push_data(self, sensor_key: str, value: Any) -> bool:
|
||||
"""Push/write data values to given key on the device."""
|
||||
return await self.api.set_data(sensor_key, value)
|
||||
|
||||
@@ -73,12 +73,10 @@ SENSORS: Final = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.RATED_CAPACITY_GEN2,
|
||||
generation=(2,),
|
||||
key=IndevoltBattery.RATED_CAPACITY,
|
||||
translation_key="rated_capacity",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltConfig.READ_DISCHARGE_LIMIT,
|
||||
@@ -132,7 +130,7 @@ SENSORS: Final = (
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.GEN_2_CYCLE_COUNT,
|
||||
generation=(2,),
|
||||
translation_key="cycle_count",
|
||||
translation_key="equivalent_full_cycles",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -794,9 +792,58 @@ SENSORS: Final = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# Battery Pack Cycles
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.MAIN_CYCLES,
|
||||
generation=(2,),
|
||||
translation_key="main_cycles",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.PACK_1_CYCLES,
|
||||
generation=(2,),
|
||||
translation_key="battery_pack_1_cycles",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.PACK_2_CYCLES,
|
||||
generation=(2,),
|
||||
translation_key="battery_pack_2_cycles",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.PACK_3_CYCLES,
|
||||
generation=(2,),
|
||||
translation_key="battery_pack_3_cycles",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.PACK_4_CYCLES,
|
||||
generation=(2,),
|
||||
translation_key="battery_pack_4_cycles",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.PACK_5_CYCLES,
|
||||
generation=(2,),
|
||||
translation_key="battery_pack_5_cycles",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Sensors per battery pack (SN, SOC, Temperature, MOS Temperature, Voltage, Current)
|
||||
# Sensors per battery pack (SN, SOC, Temperature, MOS Temperature, Voltage, Current, Cycles)
|
||||
BATTERY_PACK_SENSOR_KEYS = [
|
||||
(
|
||||
IndevoltBattery.PACK_1_SERIAL_NUMBER,
|
||||
@@ -805,6 +852,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
||||
IndevoltBattery.PACK_1_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_1_VOLTAGE,
|
||||
IndevoltBattery.PACK_1_CURRENT,
|
||||
IndevoltBattery.PACK_1_CYCLES,
|
||||
),
|
||||
(
|
||||
IndevoltBattery.PACK_2_SERIAL_NUMBER,
|
||||
@@ -813,6 +861,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
||||
IndevoltBattery.PACK_2_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_2_VOLTAGE,
|
||||
IndevoltBattery.PACK_2_CURRENT,
|
||||
IndevoltBattery.PACK_2_CYCLES,
|
||||
),
|
||||
(
|
||||
IndevoltBattery.PACK_3_SERIAL_NUMBER,
|
||||
@@ -821,6 +870,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
||||
IndevoltBattery.PACK_3_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_3_VOLTAGE,
|
||||
IndevoltBattery.PACK_3_CURRENT,
|
||||
IndevoltBattery.PACK_3_CYCLES,
|
||||
),
|
||||
(
|
||||
IndevoltBattery.PACK_4_SERIAL_NUMBER,
|
||||
@@ -829,6 +879,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
||||
IndevoltBattery.PACK_4_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_4_VOLTAGE,
|
||||
IndevoltBattery.PACK_4_CURRENT,
|
||||
IndevoltBattery.PACK_4_CYCLES,
|
||||
),
|
||||
(
|
||||
IndevoltBattery.PACK_5_SERIAL_NUMBER,
|
||||
@@ -837,6 +888,7 @@ BATTERY_PACK_SENSOR_KEYS = [
|
||||
IndevoltBattery.PACK_5_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_5_VOLTAGE,
|
||||
IndevoltBattery.PACK_5_CURRENT,
|
||||
IndevoltBattery.PACK_5_CYCLES,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -118,6 +118,9 @@
|
||||
"battery_pack_1_current": {
|
||||
"name": "Battery pack 1 current"
|
||||
},
|
||||
"battery_pack_1_cycles": {
|
||||
"name": "Battery pack 1 cycle count"
|
||||
},
|
||||
"battery_pack_1_mos_temperature": {
|
||||
"name": "Battery pack 1 MOS temperature"
|
||||
},
|
||||
@@ -136,6 +139,9 @@
|
||||
"battery_pack_2_current": {
|
||||
"name": "Battery pack 2 current"
|
||||
},
|
||||
"battery_pack_2_cycles": {
|
||||
"name": "Battery pack 2 cycle count"
|
||||
},
|
||||
"battery_pack_2_mos_temperature": {
|
||||
"name": "Battery pack 2 MOS temperature"
|
||||
},
|
||||
@@ -154,6 +160,9 @@
|
||||
"battery_pack_3_current": {
|
||||
"name": "Battery pack 3 current"
|
||||
},
|
||||
"battery_pack_3_cycles": {
|
||||
"name": "Battery pack 3 cycle count"
|
||||
},
|
||||
"battery_pack_3_mos_temperature": {
|
||||
"name": "Battery pack 3 MOS temperature"
|
||||
},
|
||||
@@ -172,6 +181,9 @@
|
||||
"battery_pack_4_current": {
|
||||
"name": "Battery pack 4 current"
|
||||
},
|
||||
"battery_pack_4_cycles": {
|
||||
"name": "Battery pack 4 cycle count"
|
||||
},
|
||||
"battery_pack_4_mos_temperature": {
|
||||
"name": "Battery pack 4 MOS temperature"
|
||||
},
|
||||
@@ -190,6 +202,9 @@
|
||||
"battery_pack_5_current": {
|
||||
"name": "Battery pack 5 current"
|
||||
},
|
||||
"battery_pack_5_cycles": {
|
||||
"name": "Battery pack 5 cycle count"
|
||||
},
|
||||
"battery_pack_5_mos_temperature": {
|
||||
"name": "Battery pack 5 MOS temperature"
|
||||
},
|
||||
@@ -226,9 +241,6 @@
|
||||
"cumulative_production": {
|
||||
"name": "Cumulative production"
|
||||
},
|
||||
"cycle_count": {
|
||||
"name": "Cycle count"
|
||||
},
|
||||
"daily_production": {
|
||||
"name": "Daily production"
|
||||
},
|
||||
@@ -283,6 +295,9 @@
|
||||
"self_consumed_prioritized": "Self-consumed prioritized"
|
||||
}
|
||||
},
|
||||
"equivalent_full_cycles": {
|
||||
"name": "Equivalent full cycles"
|
||||
},
|
||||
"grid_frequency": {
|
||||
"name": "Grid frequency"
|
||||
},
|
||||
@@ -295,6 +310,9 @@
|
||||
"main_current": {
|
||||
"name": "Main current"
|
||||
},
|
||||
"main_cycles": {
|
||||
"name": "Main cycle count"
|
||||
},
|
||||
"main_mos_temperature": {
|
||||
"name": "Main MOS temperature"
|
||||
},
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["infrared-protocols==5.6.0"]
|
||||
"requirements": ["infrared-protocols==5.6.1"]
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ def log_rate_limits(
|
||||
) -> None:
|
||||
"""Output rate limit log line at given level."""
|
||||
rate_limits = resp["rateLimits"]
|
||||
resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"])
|
||||
resetsAtTime = resetsAt - dt_util.utcnow() if resetsAt is not None else "---"
|
||||
resets_at = dt_util.parse_datetime(rate_limits["resetsAt"])
|
||||
resets_at_time = resets_at - dt_util.utcnow() if resets_at is not None else "---"
|
||||
rate_limit_msg = (
|
||||
"iOS push notification rate limits for %s: "
|
||||
"%d sent, %d allowed, %d errors, "
|
||||
@@ -44,7 +44,7 @@ def log_rate_limits(
|
||||
rate_limits["successful"],
|
||||
rate_limits["maximum"],
|
||||
rate_limits["errors"],
|
||||
str(resetsAtTime).split(".", maxsplit=1)[0],
|
||||
str(resets_at_time).split(".", maxsplit=1)[0],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -79,6 +79,17 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]
|
||||
"Unable to connect and retrieve data from israelrail api",
|
||||
) from e
|
||||
|
||||
offset = 0
|
||||
now = dt_util.now()
|
||||
while offset < len(train_routes):
|
||||
route = train_routes[offset]
|
||||
if route is None:
|
||||
break
|
||||
route_departure = departure_time(route)
|
||||
if route_departure is None or route_departure >= now:
|
||||
break
|
||||
offset += 1
|
||||
|
||||
return [
|
||||
DataConnection(
|
||||
departure=departure_time(train_routes[i]),
|
||||
@@ -89,6 +100,6 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]
|
||||
start=station_name_to_id(train_routes[i].trains[0].src),
|
||||
destination=station_name_to_id(train_routes[i].trains[-1].dst),
|
||||
)
|
||||
for i in range(DEPARTURES_COUNT)
|
||||
for i in range(offset, offset + DEPARTURES_COUNT)
|
||||
if len(train_routes) > i and train_routes[i] is not None
|
||||
]
|
||||
|
||||
@@ -52,30 +52,46 @@ DEPARTURE_SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
|
||||
IsraelRailSensorEntityDescription(
|
||||
key="platform",
|
||||
translation_key="platform",
|
||||
value_fn=lambda data_connection: data_connection.platform,
|
||||
),
|
||||
IsraelRailSensorEntityDescription(
|
||||
key="trains",
|
||||
translation_key="trains",
|
||||
value_fn=lambda data_connection: data_connection.trains,
|
||||
),
|
||||
IsraelRailSensorEntityDescription(
|
||||
key="train_number",
|
||||
translation_key="train_number",
|
||||
value_fn=lambda data_connection: data_connection.train_number,
|
||||
),
|
||||
IsraelRailSensorEntityDescription(
|
||||
key="departure_delay",
|
||||
translation_key="departure_delay",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda data_connection: data_connection.departure_delay,
|
||||
),
|
||||
*[
|
||||
IsraelRailSensorEntityDescription(
|
||||
key=f"platform{i or ''}",
|
||||
translation_key=f"platform{i or ''}",
|
||||
value_fn=lambda data_connection: data_connection.platform,
|
||||
index=i,
|
||||
)
|
||||
for i in range(DEPARTURES_COUNT)
|
||||
],
|
||||
*[
|
||||
IsraelRailSensorEntityDescription(
|
||||
key=f"trains{i or ''}",
|
||||
translation_key=f"trains{i or ''}",
|
||||
value_fn=lambda data_connection: data_connection.trains,
|
||||
index=i,
|
||||
)
|
||||
for i in range(DEPARTURES_COUNT)
|
||||
],
|
||||
*[
|
||||
IsraelRailSensorEntityDescription(
|
||||
key=f"train_number{i or ''}",
|
||||
translation_key=f"train_number{i or ''}",
|
||||
value_fn=lambda data_connection: data_connection.train_number,
|
||||
index=i,
|
||||
)
|
||||
for i in range(DEPARTURES_COUNT)
|
||||
],
|
||||
*[
|
||||
IsraelRailSensorEntityDescription(
|
||||
key=f"departure_delay{i or ''}",
|
||||
translation_key=f"departure_delay{i or ''}",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda data_connection: data_connection.departure_delay,
|
||||
index=i,
|
||||
)
|
||||
for i in range(DEPARTURES_COUNT)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -31,14 +31,38 @@
|
||||
"departure_delay": {
|
||||
"name": "Departure delay"
|
||||
},
|
||||
"departure_delay1": {
|
||||
"name": "Departure delay +1"
|
||||
},
|
||||
"departure_delay2": {
|
||||
"name": "Departure delay +2"
|
||||
},
|
||||
"platform": {
|
||||
"name": "Platform"
|
||||
},
|
||||
"platform1": {
|
||||
"name": "Platform +1"
|
||||
},
|
||||
"platform2": {
|
||||
"name": "Platform +2"
|
||||
},
|
||||
"train_number": {
|
||||
"name": "Train number"
|
||||
},
|
||||
"train_number1": {
|
||||
"name": "Train number +1"
|
||||
},
|
||||
"train_number2": {
|
||||
"name": "Train number +2"
|
||||
},
|
||||
"trains": {
|
||||
"name": "Trains"
|
||||
},
|
||||
"trains1": {
|
||||
"name": "Trains +1"
|
||||
},
|
||||
"trains2": {
|
||||
"name": "Trains +2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = (
|
||||
dict.fromkeys(_holiday.type.name for _holiday in info.holidays)
|
||||
),
|
||||
},
|
||||
next_update_fn=lambda zmanim: (
|
||||
zmanim.candle_lighting or zmanim.havdalah or zmanim.shkia.local
|
||||
),
|
||||
),
|
||||
JewishCalendarSensorDescription(
|
||||
key="omer_count",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""The LG TV RS-232 integration."""
|
||||
|
||||
from lg_rs232_tv import LGTV, TVState
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_DEVICE, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import CONF_SET_ID, LOGGER, QUERY_ATTRIBUTES, LGTVRS232ConfigEntry
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: LGTVRS232ConfigEntry) -> bool:
|
||||
"""Set up LG TV RS-232 from a config entry."""
|
||||
port = entry.data[CONF_DEVICE]
|
||||
tv = LGTV(port, set_id=entry.data[CONF_SET_ID])
|
||||
|
||||
try:
|
||||
await tv.connect()
|
||||
await tv.query(QUERY_ATTRIBUTES)
|
||||
except (ConnectionError, OSError, TimeoutError) as err:
|
||||
if tv.connected:
|
||||
await tv.disconnect()
|
||||
raise ConfigEntryNotReady(f"Error connecting to LG TV: {err}") from err
|
||||
|
||||
entry.runtime_data = tv
|
||||
|
||||
@callback
|
||||
def _on_disconnect(state: TVState | None) -> None:
|
||||
# Only reload if the entry is still loaded. During entry removal,
|
||||
# disconnect() fires this callback but the entry is already gone.
|
||||
if state is None and entry.state is ConfigEntryState.LOADED:
|
||||
LOGGER.warning("LG TV disconnected, reloading config entry")
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
entry.async_on_unload(tv.subscribe(_on_disconnect))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: LGTVRS232ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
await entry.runtime_data.disconnect()
|
||||
|
||||
return unload_ok
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Config flow for the LG TV RS-232 integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from lg_rs232_tv import DEFAULT_SET_ID, LGTV, TVNotRespondingError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_DEVICE
|
||||
from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
NumberSelectorMode,
|
||||
SerialPortSelector,
|
||||
)
|
||||
|
||||
from .const import CONF_SET_ID, DOMAIN, LOGGER
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE): SerialPortSelector(),
|
||||
vol.Required(CONF_SET_ID, default=DEFAULT_SET_ID): NumberSelector(
|
||||
NumberSelectorConfig(min=1, max=99, mode=NumberSelectorMode.BOX)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Outcome of _async_attempt_connect that means the serial port works but no LG
|
||||
# TV answered it; this routes the user to the troubleshooting step.
|
||||
RESULT_NO_TV = "no_tv"
|
||||
|
||||
|
||||
async def _async_attempt_connect(port: str, set_id: int) -> str | None:
|
||||
"""Attempt to connect to the TV at the given port.
|
||||
|
||||
Returns None on success, otherwise an outcome key: "cannot_connect" when
|
||||
the serial port could not be opened, RESULT_NO_TV when the port works but
|
||||
no LG TV responded to it, or "unknown" for an unexpected error.
|
||||
"""
|
||||
tv = LGTV(port, set_id=set_id)
|
||||
|
||||
try:
|
||||
await tv.connect()
|
||||
except TVNotRespondingError:
|
||||
# The port was opened but no LG TV responded to the power query.
|
||||
return RESULT_NO_TV
|
||||
except ValueError, ConnectionError, OSError, TimeoutError:
|
||||
# The serial port itself could not be opened.
|
||||
return "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
return "unknown"
|
||||
else:
|
||||
await tv.disconnect()
|
||||
return None
|
||||
|
||||
|
||||
class LGTVRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for LG TV RS-232."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_user_input: dict[str, Any] | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
port = user_input[CONF_DEVICE]
|
||||
set_id = user_input[CONF_SET_ID]
|
||||
|
||||
self._async_abort_entries_match({CONF_DEVICE: port, CONF_SET_ID: set_id})
|
||||
error = await _async_attempt_connect(port, set_id)
|
||||
if error is None:
|
||||
return self.async_create_entry(
|
||||
title="LG TV",
|
||||
data={CONF_DEVICE: port, CONF_SET_ID: set_id},
|
||||
)
|
||||
if error == RESULT_NO_TV:
|
||||
self._user_input = user_input
|
||||
return await self.async_step_troubleshoot()
|
||||
errors["base"] = error
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
DATA_SCHEMA, user_input or self._user_input or {}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_troubleshoot(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Guide the user to enable RS-232 control after a failed connection."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_user()
|
||||
|
||||
return self.async_show_form(step_id="troubleshoot")
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Constants for the LG TV RS-232 integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from lg_rs232_tv import LGTV
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
DOMAIN = "lg_tv_rs232"
|
||||
|
||||
CONF_SET_ID = "set_id"
|
||||
|
||||
# TVState attributes the integration polls for; the TV is not asked for
|
||||
# attributes the media player entity does not use.
|
||||
QUERY_ATTRIBUTES = ("power", "input_source", "volume", "volume_mute", "balance")
|
||||
|
||||
type LGTVRS232ConfigEntry = ConfigEntry[LGTV]
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "lg_tv_rs232",
|
||||
"name": "LG TV via Serial",
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/lg_tv_rs232",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["lg_rs232_tv"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["lg-rs232-tv==1.2.0"]
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Media player platform for the LG TV RS-232 integration."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from lg_rs232_tv import MAX_VOLUME, CommandRejected, InputSource, PowerState, TVState
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, QUERY_ATTRIBUTES, LGTVRS232ConfigEntry
|
||||
|
||||
# LG TVs do not push state over RS-232, so the entity is polled.
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
INPUT_SOURCE_LG_TO_HA: dict[InputSource, str] = {
|
||||
InputSource.DTV_ANTENNA: "dtv_antenna",
|
||||
InputSource.DTV_CABLE: "dtv_cable",
|
||||
InputSource.ANALOG_ANTENNA: "analog_antenna",
|
||||
InputSource.ANALOG_CABLE: "analog_cable",
|
||||
InputSource.AV1: "av1",
|
||||
InputSource.AV2: "av2",
|
||||
InputSource.COMPONENT1: "component1",
|
||||
InputSource.COMPONENT2: "component2",
|
||||
InputSource.COMPONENT3: "component3",
|
||||
InputSource.RGB_PC: "rgb_pc",
|
||||
InputSource.HDMI1: "hdmi1",
|
||||
InputSource.HDMI2: "hdmi2",
|
||||
InputSource.HDMI3: "hdmi3",
|
||||
InputSource.HDMI4: "hdmi4",
|
||||
}
|
||||
INPUT_SOURCE_HA_TO_LG: dict[str, InputSource] = {
|
||||
value: key for key, value in INPUT_SOURCE_LG_TO_HA.items()
|
||||
}
|
||||
|
||||
_BASE_SUPPORTED_FEATURES = (
|
||||
MediaPlayerEntityFeature.TURN_ON
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
)
|
||||
|
||||
|
||||
def catch_command_errors[**_P](
|
||||
func: Callable[_P, Coroutine[Any, Any, None]],
|
||||
) -> Callable[_P, Coroutine[Any, Any, None]]:
|
||||
"""Translate LG library errors raised by an action into HomeAssistantError."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(*args, **kwargs)
|
||||
except CommandRejected as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_rejected",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
except (ConnectionError, OSError, TimeoutError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: LGTVRS232ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the LG TV RS-232 media player."""
|
||||
async_add_entities([LGTVRS232MediaPlayer(config_entry)])
|
||||
|
||||
|
||||
class LGTVRS232MediaPlayer(MediaPlayerEntity):
|
||||
"""Representation of an LG TV controlled over RS-232."""
|
||||
|
||||
_attr_device_class = MediaPlayerDeviceClass.TV
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_translation_key = "tv"
|
||||
_attr_source_list = sorted(INPUT_SOURCE_LG_TO_HA.values())
|
||||
|
||||
def __init__(self, config_entry: LGTVRS232ConfigEntry) -> None:
|
||||
"""Initialize the media player."""
|
||||
self._tv = config_entry.runtime_data
|
||||
self._attr_unique_id = config_entry.entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, config_entry.entry_id)},
|
||||
manufacturer="LG",
|
||||
)
|
||||
self._async_update_from_state(self._tv.state)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to TV state updates."""
|
||||
self.async_on_remove(self._tv.subscribe(self._async_on_state_update))
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Poll the TV for its current state."""
|
||||
await self._tv.query(QUERY_ATTRIBUTES)
|
||||
|
||||
@callback
|
||||
def _async_on_state_update(self, state: TVState | None) -> None:
|
||||
"""Handle a state update from the TV."""
|
||||
if state is None:
|
||||
self._attr_available = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
self._async_update_from_state(state)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _async_update_from_state(self, state: TVState) -> None:
|
||||
"""Update entity attributes from a TV state snapshot."""
|
||||
if state.power is None:
|
||||
self._attr_state = None
|
||||
else:
|
||||
self._attr_state = (
|
||||
MediaPlayerState.ON
|
||||
if state.power is PowerState.ON
|
||||
else MediaPlayerState.OFF
|
||||
)
|
||||
|
||||
source = state.input_source
|
||||
self._attr_source = INPUT_SOURCE_LG_TO_HA.get(source) if source else None
|
||||
|
||||
# The TV only answers the balance query when its own speaker is the
|
||||
# active audio output. When audio is routed elsewhere (e.g. optical),
|
||||
# the TV's volume does not reflect what the user hears, so neither the
|
||||
# volume controls nor the volume attributes are exposed.
|
||||
features = _BASE_SUPPORTED_FEATURES
|
||||
if state.balance is None:
|
||||
self._attr_volume_level = None
|
||||
self._attr_is_volume_muted = None
|
||||
else:
|
||||
features |= (
|
||||
MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
)
|
||||
self._attr_volume_level = (
|
||||
None if state.volume is None else state.volume / MAX_VOLUME
|
||||
)
|
||||
self._attr_is_volume_muted = state.volume_mute
|
||||
self._attr_supported_features = features
|
||||
|
||||
@catch_command_errors
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the TV on."""
|
||||
await self._tv.power_on()
|
||||
|
||||
@catch_command_errors
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the TV off."""
|
||||
await self._tv.power_off()
|
||||
|
||||
@catch_command_errors
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
await self._tv.set_volume(round(volume * MAX_VOLUME))
|
||||
|
||||
@catch_command_errors
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute or unmute the TV."""
|
||||
if mute:
|
||||
await self._tv.mute_on()
|
||||
else:
|
||||
await self._tv.mute_off()
|
||||
|
||||
@catch_command_errors
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select an input source."""
|
||||
await self._tv.select_input_source(INPUT_SOURCE_HA_TO_LG[source])
|
||||
@@ -0,0 +1,84 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: The integration does not register custom actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: The integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: The integration has no options to configure.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: The integration does not require authentication.
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Serial devices are configured manually; there is no discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: RS-232 serial connections cannot be discovered.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: The integration does not create dynamic devices.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: The integration only provides a single primary entity.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: The media player entity uses its device class for its icon.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: The integration has no user-actionable issues to repair.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: The integration does not create devices that can become stale.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: The integration does not make HTTP requests.
|
||||
strict-typing: done
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"troubleshoot": {
|
||||
"description": "Home Assistant could not communicate with the LG TV over the serial port.\n\nThe most common cause is that **RS-232C Control** is not enabled on the TV. On most LG models this setting is in a hidden service menu (often called **InStart**); the exact location varies by model, so check your TV's documentation.\n\nAlso make sure that:\n- The TV is powered on.\n- The serial cable is a null-modem (cross-over) cable and is fully seated. LG's RS-232 jack is recessed, so push the plug in until it clicks.\n- The correct serial port was selected.\n\nSelect **Submit** to try again.",
|
||||
"title": "Connection failed"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"device": "[%key:common::config_flow::data::port%]",
|
||||
"set_id": "Set ID"
|
||||
},
|
||||
"data_description": {
|
||||
"device": "Serial port path to connect to. The TV must be powered on for the initial connection.",
|
||||
"set_id": "The set ID configured on the TV. Leave this at 1 unless you have multiple TVs daisy-chained on the same RS-232 bus."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"media_player": {
|
||||
"tv": {
|
||||
"state_attributes": {
|
||||
"source": {
|
||||
"state": {
|
||||
"analog_antenna": "Analog (antenna)",
|
||||
"analog_cable": "Analog (cable)",
|
||||
"av1": "AV 1",
|
||||
"av2": "AV 2",
|
||||
"component1": "Component 1",
|
||||
"component2": "Component 2",
|
||||
"component3": "Component 3",
|
||||
"dtv_antenna": "Digital TV (antenna)",
|
||||
"dtv_cable": "Digital TV (cable)",
|
||||
"hdmi1": "HDMI 1",
|
||||
"hdmi2": "HDMI 2",
|
||||
"hdmi3": "HDMI 3",
|
||||
"hdmi4": "HDMI 4",
|
||||
"rgb_pc": "RGB PC"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"command_failed": {
|
||||
"message": "Failed to send the command to the TV: {error}"
|
||||
},
|
||||
"command_rejected": {
|
||||
"message": "The TV rejected the command: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ async def async_handle_unload(coordinator: MadVRCoordinator) -> None:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> bool:
|
||||
"""Set up the integration from a config entry."""
|
||||
assert entry.unique_id
|
||||
madVRClient = Madvr(
|
||||
mad_vr_client = Madvr(
|
||||
host=entry.data[CONF_HOST],
|
||||
logger=_LOGGER,
|
||||
port=entry.data[CONF_PORT],
|
||||
@@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> boo
|
||||
connect_timeout=10,
|
||||
loop=hass.loop,
|
||||
)
|
||||
coordinator = MadVRCoordinator(hass, entry, madVRClient)
|
||||
coordinator = MadVRCoordinator(hass, entry, mad_vr_client)
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
|
||||
@@ -29,8 +29,10 @@ ATTR_DISPLAY_NAME = "display_name"
|
||||
ATTR_NOTE = "note"
|
||||
ATTR_AVATAR = "avatar"
|
||||
ATTR_AVATAR_MIME_TYPE = "avatar_mime_type"
|
||||
ATTR_DELETE_AVATAR = "delete_avatar"
|
||||
ATTR_HEADER = "header"
|
||||
ATTR_HEADER_MIME_TYPE = "header_mime_type"
|
||||
ATTR_DELETE_HEADER = "delete_header"
|
||||
ATTR_BOT = "bot"
|
||||
ATTR_DISCOVERABLE = "discoverable"
|
||||
ATTR_FIELDS = "fields"
|
||||
|
||||
@@ -38,6 +38,8 @@ from .const import (
|
||||
ATTR_AVATAR_MIME_TYPE,
|
||||
ATTR_BOT,
|
||||
ATTR_CONTENT_WARNING,
|
||||
ATTR_DELETE_AVATAR,
|
||||
ATTR_DELETE_HEADER,
|
||||
ATTR_DISCOVERABLE,
|
||||
ATTR_DISPLAY_NAME,
|
||||
ATTR_DURATION,
|
||||
@@ -133,8 +135,10 @@ SERVICE_UPDATE_PROFILE_SCHEMA = vol.Schema(
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
|
||||
vol.Optional(ATTR_DISPLAY_NAME): str,
|
||||
vol.Optional(ATTR_NOTE): str,
|
||||
vol.Optional(ATTR_AVATAR): MediaSelector({"accept": ["image/*"]}),
|
||||
vol.Optional(ATTR_HEADER): MediaSelector({"accept": ["image/*"]}),
|
||||
vol.Exclusive(ATTR_AVATAR, ATTR_AVATAR): MediaSelector({"accept": ["image/*"]}),
|
||||
vol.Exclusive(ATTR_DELETE_AVATAR, ATTR_AVATAR): cv.boolean,
|
||||
vol.Exclusive(ATTR_HEADER, ATTR_HEADER): MediaSelector({"accept": ["image/*"]}),
|
||||
vol.Exclusive(ATTR_DELETE_HEADER, ATTR_HEADER): cv.boolean,
|
||||
vol.Optional(ATTR_LOCKED): bool,
|
||||
vol.Optional(ATTR_BOT): bool,
|
||||
vol.Optional(ATTR_DISCOVERABLE): bool,
|
||||
@@ -404,9 +408,21 @@ async def _async_update_profile(call: ServiceCall) -> ServiceResponse | None:
|
||||
for field in fields
|
||||
if field[ATTR_NAME].strip()
|
||||
]
|
||||
delete_avatar = params.pop("delete_avatar", False)
|
||||
delete_header = params.pop("delete_header", False)
|
||||
try:
|
||||
response: Account = await call.hass.async_add_executor_job(
|
||||
lambda: client.account_update_credentials(**params)
|
||||
|
||||
def _update_profile() -> Any:
|
||||
if delete_avatar:
|
||||
client.account_delete_avatar()
|
||||
if delete_header:
|
||||
client.account_delete_header()
|
||||
if call.return_response or params:
|
||||
return client.account_update_credentials(**params)
|
||||
return None
|
||||
|
||||
response: Account | None = await call.hass.async_add_executor_job(
|
||||
_update_profile
|
||||
)
|
||||
except MastodonUnauthorizedError as error:
|
||||
entry.async_start_reauth(call.hass)
|
||||
|
||||
@@ -294,12 +294,24 @@ update_profile:
|
||||
media:
|
||||
accept:
|
||||
- "image/*"
|
||||
delete_avatar:
|
||||
required: false
|
||||
selector:
|
||||
constant:
|
||||
value: true
|
||||
label: ""
|
||||
header:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- "image/*"
|
||||
delete_header:
|
||||
required: false
|
||||
selector:
|
||||
constant:
|
||||
value: true
|
||||
label: ""
|
||||
locked:
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
@@ -283,6 +283,14 @@
|
||||
"description": "Select the Mastodon account to update the profile of.",
|
||||
"name": "[%key:component::mastodon::services::post::fields::config_entry_id::name%]"
|
||||
},
|
||||
"delete_avatar": {
|
||||
"description": "Permanently removes your current profile picture.",
|
||||
"name": "Delete profile picture"
|
||||
},
|
||||
"delete_header": {
|
||||
"description": "Permanently removes your current header picture.",
|
||||
"name": "Delete header picture"
|
||||
},
|
||||
"discoverable": {
|
||||
"description": "Whether your profile should be discoverable. Public posts and the profile may be featured or recommended across Mastodon.",
|
||||
"name": "Discoverable"
|
||||
|
||||
@@ -84,12 +84,12 @@ class MBCover(MicroBeesEntity, CoverEntity):
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
sendCommand = await self.coordinator.microbees.sendCommand(
|
||||
send_command = await self.coordinator.microbees.sendCommand(
|
||||
self.actuator_up_id,
|
||||
self.actuator_up.configuration.actuator_timing * 1000,
|
||||
)
|
||||
|
||||
if not sendCommand:
|
||||
if not send_command:
|
||||
raise HomeAssistantError(f"Failed to open {self.name}")
|
||||
|
||||
self._attr_is_opening = True
|
||||
@@ -101,11 +101,11 @@ class MBCover(MicroBeesEntity, CoverEntity):
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
sendCommand = await self.coordinator.microbees.sendCommand(
|
||||
send_command = await self.coordinator.microbees.sendCommand(
|
||||
self.actuator_down_id,
|
||||
self.actuator_down.configuration.actuator_timing * 1000,
|
||||
)
|
||||
if not sendCommand:
|
||||
if not send_command:
|
||||
raise HomeAssistantError(f"Failed to close {self.name}")
|
||||
|
||||
self._attr_is_closing = True
|
||||
|
||||
@@ -56,10 +56,10 @@ class MBLight(MicroBeesActuatorEntity, LightEntity):
|
||||
"""Turn on the light."""
|
||||
if ATTR_RGBW_COLOR in kwargs:
|
||||
self._attr_rgbw_color = kwargs[ATTR_RGBW_COLOR]
|
||||
sendCommand = await self.coordinator.microbees.sendCommand(
|
||||
send_command = await self.coordinator.microbees.sendCommand(
|
||||
self.actuator_id, 1, color=self._attr_rgbw_color
|
||||
)
|
||||
if not sendCommand:
|
||||
if not send_command:
|
||||
raise HomeAssistantError(f"Failed to turn on {self.name}")
|
||||
|
||||
self.actuator.value = True
|
||||
@@ -67,10 +67,10 @@ class MBLight(MicroBeesActuatorEntity, LightEntity):
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
sendCommand = await self.coordinator.microbees.sendCommand(
|
||||
send_command = await self.coordinator.microbees.sendCommand(
|
||||
self.actuator_id, 0, color=self._attr_rgbw_color
|
||||
)
|
||||
if not sendCommand:
|
||||
if not send_command:
|
||||
raise HomeAssistantError(f"Failed to turn off {self.name}")
|
||||
|
||||
self.actuator.value = False
|
||||
|
||||
@@ -152,8 +152,8 @@ def log_rate_limits(device_name, resp, level=logging.INFO):
|
||||
return
|
||||
|
||||
rate_limits = resp[ATTR_PUSH_RATE_LIMITS]
|
||||
resetsAt = rate_limits[ATTR_PUSH_RATE_LIMITS_RESETS_AT]
|
||||
resetsAtTime = dt_util.parse_datetime(resetsAt) - dt_util.utcnow()
|
||||
resets_at = rate_limits[ATTR_PUSH_RATE_LIMITS_RESETS_AT]
|
||||
resets_at_time = dt_util.parse_datetime(resets_at) - dt_util.utcnow()
|
||||
rate_limit_msg = (
|
||||
"mobile_app push notification rate limits for %s: "
|
||||
"%d sent, %d allowed, %d errors, "
|
||||
@@ -166,7 +166,7 @@ def log_rate_limits(device_name, resp, level=logging.INFO):
|
||||
rate_limits[ATTR_PUSH_RATE_LIMITS_SUCCESSFUL],
|
||||
rate_limits[ATTR_PUSH_RATE_LIMITS_MAXIMUM],
|
||||
rate_limits[ATTR_PUSH_RATE_LIMITS_ERRORS],
|
||||
str(resetsAtTime).split(".", maxsplit=1)[0],
|
||||
str(resets_at_time).split(".", maxsplit=1)[0],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,41 @@
|
||||
"""The opensensemap component."""
|
||||
"""The openSenseMap integration."""
|
||||
|
||||
from opensensemap_api import OpenSenseMap
|
||||
from opensensemap_api.exceptions import OpenSenseMapError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_STATION_ID
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.AIR_QUALITY]
|
||||
|
||||
type OpenSenseMapConfigEntry = ConfigEntry[OpenSenseMap]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: OpenSenseMapConfigEntry
|
||||
) -> bool:
|
||||
"""Set up openSenseMap from a config entry."""
|
||||
session = async_get_clientsession(hass)
|
||||
api = OpenSenseMap(entry.data[CONF_STATION_ID], session)
|
||||
try:
|
||||
await api.get_data()
|
||||
except OpenSenseMapError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to fetch data from openSenseMap: {err}"
|
||||
) from err
|
||||
|
||||
entry.runtime_data = api
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: OpenSenseMapConfigEntry
|
||||
) -> bool:
|
||||
"""Unload an openSenseMap config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Support for openSenseMap Air Quality data."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from opensensemap_api import OpenSenseMap
|
||||
from opensensemap_api.exceptions import OpenSenseMapError
|
||||
@@ -11,19 +10,26 @@ from homeassistant.components.air_quality import (
|
||||
PLATFORM_SCHEMA as AIR_QUALITY_PLATFORM_SCHEMA,
|
||||
AirQualityEntity,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CONF_STATION_ID = "station_id"
|
||||
from . import OpenSenseMapConfigEntry
|
||||
from .const import (
|
||||
CONF_STATION_ID,
|
||||
DEPRECATED_YAML_BREAKS_IN_VERSION,
|
||||
DOMAIN,
|
||||
INTEGRATION_TITLE,
|
||||
KNOWN_IMPORT_ABORT_REASONS,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
@@ -38,23 +44,67 @@ async def async_setup_platform(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the openSenseMap air quality platform."""
|
||||
"""Import legacy YAML configuration into a config entry."""
|
||||
# Keep the legacy platform entry point so existing YAML is migrated into a
|
||||
# config entry instead of adding entities directly from YAML.
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=config,
|
||||
)
|
||||
|
||||
name = config.get(CONF_NAME)
|
||||
station_id = config[CONF_STATION_ID]
|
||||
if (
|
||||
result["type"] is FlowResultType.ABORT
|
||||
and result["reason"] in KNOWN_IMPORT_ABORT_REASONS
|
||||
):
|
||||
# Per-reason issue conveys the deprecation notice itself, so don't also
|
||||
# raise the generic deprecated_yaml issue on top of it.
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_yaml_import_issue_{result['reason']}",
|
||||
breaks_in_ha_version=DEPRECATED_YAML_BREAKS_IN_VERSION,
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": INTEGRATION_TITLE,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
osm_api = OpenSenseMapData(OpenSenseMap(station_id, session))
|
||||
# "deprecated_yaml" translation key lives under the "homeassistant" core domain.
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
breaks_in_ha_version=DEPRECATED_YAML_BREAKS_IN_VERSION,
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": INTEGRATION_TITLE,
|
||||
},
|
||||
)
|
||||
|
||||
await osm_api.async_update()
|
||||
|
||||
if "name" not in osm_api.api.data:
|
||||
_LOGGER.error("Station %s is not available", station_id)
|
||||
raise PlatformNotReady
|
||||
|
||||
station_name = osm_api.api.data["name"] if name is None else name
|
||||
|
||||
async_add_entities([OpenSenseMapQuality(station_name, osm_api)], True)
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OpenSenseMapConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the openSenseMap air quality entity from a config entry."""
|
||||
async_add_entities(
|
||||
[
|
||||
OpenSenseMapQuality(
|
||||
entry.runtime_data, entry.data[CONF_STATION_ID], entry.title
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class OpenSenseMapQuality(AirQualityEntity):
|
||||
@@ -62,43 +112,28 @@ class OpenSenseMapQuality(AirQualityEntity):
|
||||
|
||||
_attr_attribution = "Data provided by openSenseMap"
|
||||
|
||||
def __init__(self, name, osm):
|
||||
def __init__(self, api: OpenSenseMap, station_id: str, name: str) -> None:
|
||||
"""Initialize the air quality entity."""
|
||||
self._name = name
|
||||
self._osm = osm
|
||||
self._api = api
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = station_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the air quality entity."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def particulate_matter_2_5(self):
|
||||
def particulate_matter_2_5(self) -> float | None:
|
||||
"""Return the particulate matter 2.5 level."""
|
||||
return self._osm.api.pm2_5
|
||||
return self._api.pm2_5
|
||||
|
||||
@property
|
||||
def particulate_matter_10(self):
|
||||
def particulate_matter_10(self) -> float | None:
|
||||
"""Return the particulate matter 10 level."""
|
||||
return self._osm.api.pm10
|
||||
|
||||
async def async_update(self):
|
||||
"""Get the latest data from the openSenseMap API."""
|
||||
await self._osm.async_update()
|
||||
|
||||
|
||||
class OpenSenseMapData:
|
||||
"""Get the latest data and update the states."""
|
||||
|
||||
def __init__(self, api):
|
||||
"""Initialize the data object."""
|
||||
self.api = api
|
||||
|
||||
@Throttle(SCAN_INTERVAL)
|
||||
async def async_update(self):
|
||||
"""Get the latest data from the Pi-hole."""
|
||||
return self._api.pm10
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Fetch latest data from the openSenseMap API."""
|
||||
try:
|
||||
await self.api.get_data()
|
||||
await self._api.get_data()
|
||||
except OpenSenseMapError as err:
|
||||
_LOGGER.error("Unable to fetch data: %s", err)
|
||||
LOGGER.warning("Unable to fetch data from openSenseMap: %s", err)
|
||||
self._attr_available = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Config flow for the openSenseMap integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from opensensemap_api import OpenSenseMap
|
||||
from opensensemap_api.exceptions import OpenSenseMapError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_STATION_ID, DOMAIN, ERROR_CANNOT_CONNECT, ERROR_INVALID_STATION
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate the openSenseMap API is unreachable."""
|
||||
|
||||
|
||||
class InvalidStation(HomeAssistantError):
|
||||
"""Error to indicate the station ID does not exist."""
|
||||
|
||||
|
||||
class OpenSenseMapConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for openSenseMap."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def _async_get_station_name(self, station_id: str) -> str:
|
||||
"""Validate the station ID and return its name."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
api = OpenSenseMap(station_id, session)
|
||||
try:
|
||||
# opensensemap_api wraps the request in a 5s aiohttp.ClientTimeout
|
||||
# and re-raises asyncio.TimeoutError as OpenSenseMapConnectionError.
|
||||
await api.get_data()
|
||||
except OpenSenseMapError as err:
|
||||
raise CannotConnect from err
|
||||
if not api.data or not api.data.get("name"):
|
||||
raise InvalidStation
|
||||
return api.data["name"]
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a user-initiated config flow."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
station_id = user_input[CONF_STATION_ID]
|
||||
try:
|
||||
name = await self._async_get_station_name(station_id)
|
||||
except CannotConnect:
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except InvalidStation:
|
||||
errors["base"] = ERROR_INVALID_STATION
|
||||
else:
|
||||
await self.async_set_unique_id(station_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={CONF_STATION_ID: station_id},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Required(CONF_STATION_ID): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle import of a YAML configuration."""
|
||||
station_id = import_data[CONF_STATION_ID]
|
||||
await self.async_set_unique_id(station_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Even when YAML provides a display name, validate the station before
|
||||
# migrating so broken YAML does not create an entry that cannot set up.
|
||||
try:
|
||||
name = await self._async_get_station_name(station_id)
|
||||
except CannotConnect:
|
||||
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
|
||||
except InvalidStation:
|
||||
return self.async_abort(reason=ERROR_INVALID_STATION)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=import_data.get(CONF_NAME) or name,
|
||||
data={CONF_STATION_ID: station_id},
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Constants for the openSenseMap integration."""
|
||||
|
||||
import logging
|
||||
|
||||
DOMAIN = "opensensemap"
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_STATION_ID = "station_id"
|
||||
|
||||
INTEGRATION_TITLE = "openSenseMap"
|
||||
DEPRECATED_YAML_BREAKS_IN_VERSION = "2026.12.0"
|
||||
|
||||
ERROR_CANNOT_CONNECT = "cannot_connect"
|
||||
ERROR_INVALID_STATION = "invalid_station"
|
||||
KNOWN_IMPORT_ABORT_REASONS = (ERROR_CANNOT_CONNECT, ERROR_INVALID_STATION)
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"domain": "opensensemap",
|
||||
"name": "openSenseMap",
|
||||
"codeowners": [],
|
||||
"codeowners": ["@AlCalzone"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/opensensemap",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opensensemap_api"],
|
||||
"quality_scale": "legacy",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "Failed to connect to openSenseMap.",
|
||||
"invalid_station": "The provided station ID does not exist on openSenseMap."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_station": "[%key:component::opensensemap::config::abort::invalid_station%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"station_id": "Station ID"
|
||||
},
|
||||
"data_description": {
|
||||
"station_id": "The unique identifier of your openSenseMap station. You can find it in the URL when viewing the station on opensensemap.org."
|
||||
},
|
||||
"description": "Add an openSenseMap station to monitor its measurements.",
|
||||
"title": "Add openSenseMap station"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue_cannot_connect": {
|
||||
"description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to set up the integration manually.",
|
||||
"title": "The {integration_title} YAML configuration import failed"
|
||||
},
|
||||
"deprecated_yaml_import_issue_invalid_station": {
|
||||
"description": "Configuring {integration_title} using YAML is being removed but the configured station could not be found.\n\nVerify the station ID and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to set up the integration manually.",
|
||||
"title": "[%key:component::opensensemap::issues::deprecated_yaml_import_issue_cannot_connect::title%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,14 +146,14 @@ async def determine_api_version(
|
||||
debugging.
|
||||
"""
|
||||
|
||||
holeV6 = api_by_version(hass, entry, 6, password="wrong_password")
|
||||
hole_v6 = api_by_version(hass, entry, 6, password="wrong_password")
|
||||
try:
|
||||
await holeV6.authenticate()
|
||||
await hole_v6.authenticate()
|
||||
except HoleConnectionError as err:
|
||||
_LOGGER.error(
|
||||
"Unexpected error connecting to Pi-hole v6 API"
|
||||
" at %s: %s. Trying version 5 API",
|
||||
holeV6.base_url,
|
||||
hole_v6.base_url,
|
||||
err,
|
||||
)
|
||||
# Ideally python-hole would raise a specific exception for authentication failures
|
||||
@@ -161,12 +161,12 @@ async def determine_api_version(
|
||||
if str(ex_v6) == "Authentication failed: Invalid password":
|
||||
_LOGGER.debug(
|
||||
"Success connecting to Pi-hole at %s without auth, API version is : %s",
|
||||
holeV6.base_url,
|
||||
hole_v6.base_url,
|
||||
6,
|
||||
)
|
||||
return 6
|
||||
_LOGGER.debug(
|
||||
"Connection to %s failed: %s, trying API version 5", holeV6.base_url, ex_v6
|
||||
"Connection to %s failed: %s, trying API version 5", hole_v6.base_url, ex_v6
|
||||
)
|
||||
else:
|
||||
# It seems that occasionally the auth can succeed
|
||||
@@ -175,34 +175,34 @@ async def determine_api_version(
|
||||
"Authenticated with %s through v6 API, but"
|
||||
" succeeded with an incorrect password."
|
||||
" This is a known bug",
|
||||
holeV6.base_url,
|
||||
hole_v6.base_url,
|
||||
)
|
||||
return 6
|
||||
holeV5 = api_by_version(hass, entry, 5, password="wrong_token")
|
||||
hole_v5 = api_by_version(hass, entry, 5, password="wrong_token")
|
||||
try:
|
||||
await holeV5.get_data()
|
||||
await hole_v5.get_data()
|
||||
|
||||
except HoleConnectionError as err:
|
||||
_LOGGER.error(
|
||||
"Failed to connect to Pi-hole v5 API at %s: %s", holeV5.base_url, err
|
||||
"Failed to connect to Pi-hole v5 API at %s: %s", hole_v5.base_url, err
|
||||
)
|
||||
else:
|
||||
# V5 API returns [] to unauthenticated requests
|
||||
if not holeV5.data:
|
||||
if not hole_v5.data:
|
||||
_LOGGER.debug(
|
||||
"Response '[]' from API without auth,"
|
||||
" pihole API version 5 probably"
|
||||
" detected at %s",
|
||||
holeV5.base_url,
|
||||
hole_v5.base_url,
|
||||
)
|
||||
return 5
|
||||
_LOGGER.debug(
|
||||
"Unexpected response from Pi-hole API at %s: %s",
|
||||
holeV5.base_url,
|
||||
str(holeV5.data),
|
||||
hole_v5.base_url,
|
||||
str(hole_v5.data),
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Could not determine pi-hole API version at: %s",
|
||||
holeV6.base_url,
|
||||
hole_v6.base_url,
|
||||
)
|
||||
raise HoleError("Could not determine Pi-hole API version")
|
||||
|
||||
@@ -84,7 +84,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
coordinator.async_add_listener(_check_outputs)
|
||||
|
||||
|
||||
class QbusWeatherBinarySensor(QbusEntity, BinarySensorEntity):
|
||||
|
||||
@@ -51,7 +51,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
coordinator.async_add_listener(_check_outputs)
|
||||
|
||||
|
||||
class QbusClimate(QbusEntity, ClimateEntity):
|
||||
|
||||
@@ -57,10 +57,7 @@ class QbusControllerCoordinator(DataUpdateCoordinator[QbusMqttDevice | None]):
|
||||
self._subscribed_to_controller_state = False
|
||||
self._controller: QbusMqttDevice | None = None
|
||||
|
||||
# Clean up when HA stops
|
||||
self.config_entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown)
|
||||
)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown)
|
||||
|
||||
async def _async_update_data(self) -> QbusMqttDevice | None:
|
||||
return self._controller
|
||||
@@ -126,12 +123,10 @@ class QbusControllerCoordinator(DataUpdateCoordinator[QbusMqttDevice | None]):
|
||||
controller_state_topic,
|
||||
)
|
||||
self._subscribed_to_controller_state = True
|
||||
self.config_entry.async_on_unload(
|
||||
await mqtt.async_subscribe(
|
||||
self.hass,
|
||||
controller_state_topic,
|
||||
self._controller_state_received,
|
||||
)
|
||||
await mqtt.async_subscribe(
|
||||
self.hass,
|
||||
controller_state_topic,
|
||||
self._controller_state_received,
|
||||
)
|
||||
|
||||
async def _controller_state_received(self, msg: ReceiveMessage) -> None:
|
||||
|
||||
@@ -45,7 +45,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
coordinator.async_add_listener(_check_outputs)
|
||||
|
||||
|
||||
class QbusCover(QbusEntity, CoverEntity):
|
||||
|
||||
@@ -36,7 +36,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
coordinator.async_add_listener(_check_outputs)
|
||||
|
||||
|
||||
class QbusLight(QbusEntity, LightEntity):
|
||||
|
||||
@@ -35,7 +35,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
coordinator.async_add_listener(_check_outputs)
|
||||
|
||||
|
||||
class QbusScene(QbusEntity, BaseScene):
|
||||
|
||||
@@ -38,7 +38,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
coordinator.async_add_listener(_check_outputs)
|
||||
|
||||
|
||||
class QbusStepper(QbusEntity, SelectEntity):
|
||||
|
||||
@@ -291,7 +291,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
coordinator.async_add_listener(_check_outputs)
|
||||
|
||||
|
||||
class QbusGaugeVariantSensor(QbusEntity, SensorEntity):
|
||||
|
||||
@@ -35,7 +35,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
coordinator.async_add_listener(_check_outputs)
|
||||
|
||||
|
||||
class QbusSwitch(QbusEntity, SwitchEntity):
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/qingping",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["qingping-ble==1.1.0"]
|
||||
"requirements": ["qingping-ble==1.1.4"]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Support for Renault devices."""
|
||||
|
||||
import aiohttp
|
||||
from renault_api.exceptions import NotAuthenticatedException
|
||||
from renault_api.gigya.exceptions import GigyaException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
@@ -29,19 +29,11 @@ async def async_setup_entry(
|
||||
) -> bool:
|
||||
"""Load a config entry."""
|
||||
renault_hub = RenaultHub(hass, config_entry.data[CONF_LOCALE])
|
||||
try:
|
||||
login_success = await renault_hub.attempt_login(
|
||||
config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]
|
||||
)
|
||||
except (aiohttp.ClientConnectionError, GigyaException) as exc:
|
||||
raise ConfigEntryNotReady from exc
|
||||
|
||||
if not login_success:
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
try:
|
||||
await renault_hub.async_initialise(config_entry)
|
||||
except aiohttp.ClientError as exc:
|
||||
except NotAuthenticatedException as exc:
|
||||
raise ConfigEntryAuthFailed from exc
|
||||
except (aiohttp.ClientError, GigyaException) as exc:
|
||||
raise ConfigEntryNotReady from exc
|
||||
|
||||
config_entry.runtime_data = renault_hub
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiohttp
|
||||
from renault_api.const import AVAILABLE_LOCALES
|
||||
@@ -16,7 +16,7 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN
|
||||
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, CONF_LOGIN_TOKEN, DOMAIN
|
||||
from .renault_hub import RenaultHub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -65,6 +65,9 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if login_success:
|
||||
if TYPE_CHECKING:
|
||||
assert self.renault_hub.login_token
|
||||
self.renault_config[CONF_LOGIN_TOKEN] = self.renault_hub.login_token
|
||||
return await self.async_step_kamereon()
|
||||
errors["base"] = "invalid_credentials"
|
||||
suggested_values = user_input
|
||||
@@ -133,9 +136,14 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if await self.renault_hub.attempt_login(
|
||||
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
):
|
||||
if TYPE_CHECKING:
|
||||
assert self.renault_hub.login_token
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
data_updates={
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_LOGIN_TOKEN: self.renault_hub.login_token,
|
||||
},
|
||||
)
|
||||
errors = {"base": "invalid_credentials"}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ DOMAIN = "renault"
|
||||
|
||||
CONF_LOCALE = "locale"
|
||||
CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id"
|
||||
CONF_LOGIN_TOKEN = "login_token"
|
||||
|
||||
# normal number of allowed calls per hour to the API
|
||||
# for a single car and the 7 coordinator, it is a scan every 7mn
|
||||
|
||||
@@ -8,11 +8,12 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from . import RenaultConfigEntry
|
||||
from .const import CONF_KAMEREON_ACCOUNT_ID
|
||||
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOGIN_TOKEN
|
||||
from .renault_vehicle import RenaultVehicleProxy
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_KAMEREON_ACCOUNT_ID,
|
||||
CONF_LOGIN_TOKEN,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
"radioCode",
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from renault_api.exceptions import NotAuthenticatedException
|
||||
from renault_api.gigya.exceptions import InvalidCredentialsException
|
||||
from renault_api.kamereon.models import KamereonVehiclesLink
|
||||
from renault_api.renault_account import RenaultAccount
|
||||
@@ -16,6 +17,8 @@ from homeassistant.const import (
|
||||
ATTR_MODEL,
|
||||
ATTR_MODEL_ID,
|
||||
ATTR_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -29,6 +32,7 @@ from time import time
|
||||
|
||||
from .const import (
|
||||
CONF_KAMEREON_ACCOUNT_ID,
|
||||
CONF_LOGIN_TOKEN,
|
||||
COOLING_UPDATES_SECONDS,
|
||||
MAX_CALLS_PER_HOURS,
|
||||
)
|
||||
@@ -68,6 +72,11 @@ class RenaultHub:
|
||||
|
||||
self._got_throttled_at_time: float | None = None
|
||||
|
||||
@property
|
||||
def login_token(self) -> str | None:
|
||||
"""Return the Gigya login token obtained from a successful login."""
|
||||
return self._client.session.login_token
|
||||
|
||||
def set_throttled(self) -> None:
|
||||
"""We got throttled, we need to adjust the rate limit."""
|
||||
if self._got_throttled_at_time is None:
|
||||
@@ -96,6 +105,20 @@ class RenaultHub:
|
||||
|
||||
async def async_initialise(self, config_entry: RenaultConfigEntry) -> None:
|
||||
"""Set up proxy."""
|
||||
# Reuse the stored login token, or fall back to a password login.
|
||||
if login_token := config_entry.data.get(CONF_LOGIN_TOKEN):
|
||||
self._client.session.set_login_token(login_token)
|
||||
elif await self.attempt_login(
|
||||
config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]
|
||||
):
|
||||
# Persist the login token so the next setup can skip the password.
|
||||
self._hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={**config_entry.data, CONF_LOGIN_TOKEN: self.login_token},
|
||||
)
|
||||
else:
|
||||
raise NotAuthenticatedException
|
||||
|
||||
account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID]
|
||||
|
||||
self._account = await self._client.get_api_account(account_id)
|
||||
|
||||
@@ -15,15 +15,15 @@ async def async_get_config_entry_diagnostics(
|
||||
host = reolink_data.host
|
||||
api = host.api
|
||||
|
||||
IPC_cam: dict[int, dict[str, Any]] = {}
|
||||
ipc_cam: dict[int, dict[str, Any]] = {}
|
||||
for ch in api.channels:
|
||||
IPC_cam[ch] = {}
|
||||
IPC_cam[ch]["model"] = api.camera_model(ch)
|
||||
IPC_cam[ch]["hardware version"] = api.camera_hardware_version(ch)
|
||||
IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch)
|
||||
IPC_cam[ch]["encoding main"] = await api.get_encoding(ch)
|
||||
ipc_cam[ch] = {}
|
||||
ipc_cam[ch]["model"] = api.camera_model(ch)
|
||||
ipc_cam[ch]["hardware version"] = api.camera_hardware_version(ch)
|
||||
ipc_cam[ch]["firmware version"] = api.camera_sw_version(ch)
|
||||
ipc_cam[ch]["encoding main"] = await api.get_encoding(ch)
|
||||
if (signal := api.wifi_signal(ch)) is not None and api.wifi_connection(ch):
|
||||
IPC_cam[ch]["WiFi signal"] = signal
|
||||
ipc_cam[ch]["WiFi signal"] = signal
|
||||
|
||||
chimes: dict[int, dict[str, Any]] = {}
|
||||
for chime in api.chime_list:
|
||||
@@ -50,7 +50,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"stream protocol": api.protocol,
|
||||
"channels": api.channels,
|
||||
"stream channels": api.stream_channels,
|
||||
"IPC cams": IPC_cam,
|
||||
"IPC cams": ipc_cam,
|
||||
"Chimes": chimes,
|
||||
"capabilities": api.capabilities,
|
||||
"cmd list": host.update_cmd,
|
||||
|
||||
@@ -47,21 +47,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolarlogConfigEntry) ->
|
||||
|
||||
basic_coordinator = SolarLogBasicDataCoordinator(hass, entry, solarlog)
|
||||
|
||||
solarLogData = SolarlogIntegrationData(
|
||||
solar_log_data = SolarlogIntegrationData(
|
||||
api=solarlog,
|
||||
basic_data_coordinator=basic_coordinator,
|
||||
)
|
||||
|
||||
await basic_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = solarLogData
|
||||
entry.runtime_data = solar_log_data
|
||||
|
||||
_LOGGER.debug(
|
||||
"Basic coordinator setup successful, extended data available: %s",
|
||||
solarLogData.api.extended_data,
|
||||
solar_log_data.api.extended_data,
|
||||
)
|
||||
|
||||
if solarLogData.api.extended_data:
|
||||
if solar_log_data.api.extended_data:
|
||||
timeout = entry.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT)
|
||||
|
||||
_LOGGER.debug("Setup of LongtimeDataCoordinator, saved timeout is %s", timeout)
|
||||
|
||||
@@ -335,40 +335,43 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add solarlog entry."""
|
||||
|
||||
solarLogIntegrationData: SolarlogIntegrationData = entry.runtime_data
|
||||
solar_log_integration_data: SolarlogIntegrationData = entry.runtime_data
|
||||
|
||||
entities: list[SensorEntity] = [
|
||||
SolarLogBasicCoordinatorSensor(
|
||||
solarLogIntegrationData.basic_data_coordinator, sensor
|
||||
solar_log_integration_data.basic_data_coordinator, sensor
|
||||
)
|
||||
for sensor in SOLARLOG_BASIC_SENSOR_TYPES
|
||||
]
|
||||
|
||||
if solarLogIntegrationData.longtime_data_coordinator is not None:
|
||||
if solar_log_integration_data.longtime_data_coordinator is not None:
|
||||
entities.extend(
|
||||
SolarLogLongtimeCoordinatorSensor(
|
||||
solarLogIntegrationData.longtime_data_coordinator, sensor
|
||||
solar_log_integration_data.longtime_data_coordinator, sensor
|
||||
)
|
||||
for sensor in SOLARLOG_LONGTIME_SENSOR_TYPES
|
||||
)
|
||||
|
||||
# add battery sensors only if respective data is
|
||||
# available (otherwise no battery attached to solarlog)
|
||||
if solarLogIntegrationData.basic_data_coordinator.data.battery_data is not None:
|
||||
if (
|
||||
solar_log_integration_data.basic_data_coordinator.data.battery_data
|
||||
is not None
|
||||
):
|
||||
entities.extend(
|
||||
SolarLogBatterySensor(
|
||||
solarLogIntegrationData.basic_data_coordinator, sensor
|
||||
solar_log_integration_data.basic_data_coordinator, sensor
|
||||
)
|
||||
for sensor in SOLARLOG_BATTERY_SENSOR_TYPES
|
||||
)
|
||||
|
||||
if solarLogIntegrationData.device_data_coordinator is not None:
|
||||
device_data = solarLogIntegrationData.device_data_coordinator.data
|
||||
if solar_log_integration_data.device_data_coordinator is not None:
|
||||
device_data = solar_log_integration_data.device_data_coordinator.data
|
||||
|
||||
if device_data:
|
||||
entities.extend(
|
||||
SolarLogInverterSensor(
|
||||
solarLogIntegrationData.device_data_coordinator,
|
||||
solar_log_integration_data.device_data_coordinator,
|
||||
sensor,
|
||||
device_id,
|
||||
)
|
||||
@@ -379,15 +382,15 @@ async def async_setup_entry(
|
||||
def _async_add_new_device(device_id: int) -> None:
|
||||
async_add_entities(
|
||||
SolarLogInverterSensor(
|
||||
solarLogIntegrationData.device_data_coordinator,
|
||||
solar_log_integration_data.device_data_coordinator,
|
||||
sensor,
|
||||
device_id,
|
||||
)
|
||||
for sensor in SOLARLOG_INVERTER_SENSOR_TYPES
|
||||
if solarLogIntegrationData.device_data_coordinator is not None
|
||||
if solar_log_integration_data.device_data_coordinator is not None
|
||||
)
|
||||
|
||||
solarLogIntegrationData.device_data_coordinator.new_device_callbacks.append(
|
||||
solar_log_integration_data.device_data_coordinator.new_device_callbacks.append(
|
||||
_async_add_new_device
|
||||
)
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ PLATFORMS_BY_TYPE = {
|
||||
],
|
||||
SupportedModels.LOCK_ULTRA.value: [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.LOCK,
|
||||
Platform.SENSOR,
|
||||
],
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from switchbot import SwitchbotModel
|
||||
import switchbot
|
||||
from switchbot import LockStatus, SwitchbotModel
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -19,12 +20,17 @@ from .entity import SwitchbotEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
LOCK_ULTRA_BINARY_SENSORS = {"half_lock_calibration", "half_locked"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SwitchbotBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes Switchbot binary sensor entity."""
|
||||
|
||||
device_class_fn: Callable[[SwitchbotModel], BinarySensorDeviceClass] | None = None
|
||||
value_fn: Callable[[switchbot.SwitchbotDevice, str], bool | None] = (
|
||||
lambda device, key: device.parsed_data.get(key)
|
||||
)
|
||||
|
||||
|
||||
BINARY_SENSOR_TYPES: dict[str, SwitchbotBinarySensorEntityDescription] = {
|
||||
@@ -33,6 +39,20 @@ BINARY_SENSOR_TYPES: dict[str, SwitchbotBinarySensorEntityDescription] = {
|
||||
translation_key="calibration",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"half_lock_calibration": SwitchbotBinarySensorEntityDescription(
|
||||
key="half_lock_calibration",
|
||||
translation_key="half_lock_calibration",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"half_locked": SwitchbotBinarySensorEntityDescription(
|
||||
key="half_locked",
|
||||
translation_key="half_locked",
|
||||
value_fn=lambda device, _: (
|
||||
None
|
||||
if (status := device.get_lock_status()) is None
|
||||
else status is LockStatus.HALF_LOCKED
|
||||
),
|
||||
),
|
||||
"motion_detected": SwitchbotBinarySensorEntityDescription(
|
||||
key="pir_state",
|
||||
device_class_fn=lambda model: {
|
||||
@@ -100,10 +120,14 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Switchbot curtain based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
binary_sensors: set[str] = {
|
||||
bs for bs in coordinator.device.parsed_data if bs in BINARY_SENSOR_TYPES
|
||||
}
|
||||
if coordinator.model is SwitchbotModel.LOCK_ULTRA:
|
||||
binary_sensors.update(LOCK_ULTRA_BINARY_SENSORS)
|
||||
async_add_entities(
|
||||
SwitchBotBinarySensor(coordinator, binary_sensor)
|
||||
for binary_sensor in coordinator.device.parsed_data
|
||||
if binary_sensor in BINARY_SENSOR_TYPES
|
||||
for binary_sensor in binary_sensors
|
||||
)
|
||||
|
||||
|
||||
@@ -128,6 +152,6 @@ class SwitchBotBinarySensor(SwitchbotEntity, BinarySensorEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.parsed_data[self._sensor]
|
||||
return self.entity_description.value_fn(self._device, self._sensor)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
|
||||
import switchbot
|
||||
from switchbot import SwitchbotModel
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
@@ -10,6 +11,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH
|
||||
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
|
||||
from .entity import SwitchbotEntity, exception_handler
|
||||
|
||||
@@ -24,11 +26,13 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Switchbot button platform."""
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[ButtonEntity] = []
|
||||
|
||||
if isinstance(coordinator.device, switchbot.SwitchbotAirPurifier):
|
||||
async_add_entities([LightSensorButton(coordinator)])
|
||||
entities.append(LightSensorButton(coordinator))
|
||||
|
||||
if isinstance(coordinator.device, switchbot.SwitchbotArtFrame):
|
||||
async_add_entities(
|
||||
entities.extend(
|
||||
[
|
||||
SwitchBotArtFrameNextButton(coordinator, "next_image"),
|
||||
SwitchBotArtFramePrevButton(coordinator, "previous_image"),
|
||||
@@ -36,7 +40,17 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
if isinstance(coordinator.device, switchbot.SwitchbotMeterProCO2):
|
||||
async_add_entities([SwitchBotMeterProCO2SyncDateTimeButton(coordinator)])
|
||||
entities.append(SwitchBotMeterProCO2SyncDateTimeButton(coordinator))
|
||||
|
||||
if (
|
||||
isinstance(coordinator.device, switchbot.SwitchbotLock)
|
||||
and coordinator.model is SwitchbotModel.LOCK_ULTRA
|
||||
and entry.options.get(CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH)
|
||||
):
|
||||
entities.append(HalfLockButton(coordinator))
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class LightSensorButton(SwitchbotEntity, ButtonEntity):
|
||||
@@ -132,3 +146,21 @@ class SwitchBotMeterProCO2SyncDateTimeButton(SwitchbotEntity, ButtonEntity):
|
||||
utc_offset_hours=utc_offset_hours,
|
||||
utc_offset_minutes=utc_offset_minutes,
|
||||
)
|
||||
|
||||
|
||||
class HalfLockButton(SwitchbotEntity, ButtonEntity):
|
||||
"""Representation of a Half Lock button for Lock Ultra."""
|
||||
|
||||
_attr_translation_key = "half_lock"
|
||||
_device: switchbot.SwitchbotLock
|
||||
|
||||
def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
|
||||
"""Initialize the Half Lock button."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.base_unique_id}_half_lock"
|
||||
|
||||
@exception_handler
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
_LOGGER.debug("Sending half lock command for %s", self._address)
|
||||
await self._device.half_lock()
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"half_lock_calibration": {
|
||||
"default": "mdi:lock-check"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"light_sensor": {
|
||||
"default": "mdi:brightness-auto"
|
||||
|
||||
@@ -46,7 +46,7 @@ class SwitchBotLock(SwitchbotEntity, LockEntity):
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Update the entity attributes."""
|
||||
status = self._device.get_lock_status()
|
||||
self._attr_is_locked = status is LockStatus.LOCKED
|
||||
self._attr_is_locked = status in {LockStatus.LOCKED, LockStatus.HALF_LOCKED}
|
||||
self._attr_is_locking = status is LockStatus.LOCKING
|
||||
self._attr_is_unlocking = status is LockStatus.UNLOCKING
|
||||
self._attr_is_jammed = status in {
|
||||
|
||||
@@ -99,9 +99,18 @@
|
||||
},
|
||||
"door_unlocked_alarm": {
|
||||
"name": "Unlocked alarm"
|
||||
},
|
||||
"half_lock_calibration": {
|
||||
"name": "Half-lock calibration"
|
||||
},
|
||||
"half_locked": {
|
||||
"name": "Half locked"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"half_lock": {
|
||||
"name": "Half lock"
|
||||
},
|
||||
"light_sensor": {
|
||||
"name": "Light sensor"
|
||||
},
|
||||
|
||||
@@ -326,7 +326,7 @@ async def make_new_device_data(
|
||||
manageable_by_webhook=default_config.webhook,
|
||||
)
|
||||
|
||||
_PLATFORM_LIST_MAP: dict[Platform, list] = {
|
||||
_platform_list_map: dict[Platform, list] = {
|
||||
Platform.BINARY_SENSOR: devices_data.binary_sensors,
|
||||
Platform.BUTTON: devices_data.buttons,
|
||||
Platform.CLIMATE: devices_data.climates,
|
||||
@@ -342,7 +342,7 @@ async def make_new_device_data(
|
||||
}
|
||||
|
||||
for platform in default_config.entity_config:
|
||||
target_list = _PLATFORM_LIST_MAP.get(platform)
|
||||
target_list = _platform_list_map.get(platform)
|
||||
if target_list is None:
|
||||
continue
|
||||
existing_ids = {item[0].device_id for item in target_list}
|
||||
@@ -502,14 +502,14 @@ def _create_handle_webhook(
|
||||
_LOGGER.debug("Received invalid data from switchbot webhook %s", repr(data))
|
||||
return
|
||||
_LOGGER.debug("Received data from switchbot webhook: %s", repr(data))
|
||||
deviceMac = data["context"]["deviceMac"]
|
||||
device_mac = data["context"]["deviceMac"]
|
||||
|
||||
if deviceMac not in coordinators_by_id:
|
||||
if device_mac not in coordinators_by_id:
|
||||
_LOGGER.error(
|
||||
"Received data for unknown entity from switchbot webhook: %s", data
|
||||
)
|
||||
return
|
||||
|
||||
coordinators_by_id[deviceMac].async_set_updated_data(data["context"])
|
||||
coordinators_by_id[device_mac].async_set_updated_data(data["context"])
|
||||
|
||||
return _internal_handle_webhook
|
||||
|
||||
@@ -86,23 +86,24 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
entry: VelbusConfigEntry = service.async_get_config_entry(
|
||||
call.hass, DOMAIN, call.data[CONF_CONFIG_ENTRY]
|
||||
)
|
||||
try:
|
||||
|
||||
def _clear_cache() -> None:
|
||||
if call.data.get(CONF_ADDRESS):
|
||||
await hass.async_add_executor_job(
|
||||
os.unlink,
|
||||
hass.config.path(
|
||||
STORAGE_DIR,
|
||||
f"velbuscache-{entry.entry_id}/{call.data[CONF_ADDRESS]}.p",
|
||||
),
|
||||
cache_path = hass.config.path(
|
||||
STORAGE_DIR,
|
||||
f"velbuscache-{entry.entry_id}/{call.data[CONF_ADDRESS]}.p",
|
||||
)
|
||||
if os.path.exists(cache_path):
|
||||
os.unlink(cache_path)
|
||||
else:
|
||||
await hass.async_add_executor_job(
|
||||
shutil.rmtree,
|
||||
hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}/"),
|
||||
cache_path = hass.config.path(
|
||||
STORAGE_DIR, f"velbuscache-{entry.entry_id}/"
|
||||
)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except FileNotFoundError:
|
||||
pass # It's okay if the file doesn't exist
|
||||
if os.path.isdir(cache_path):
|
||||
shutil.rmtree(cache_path)
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(_clear_cache)
|
||||
except OSError as exc:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -20,7 +20,6 @@ from .voip import HassVoipDatagramProtocol
|
||||
|
||||
PLATFORMS = (
|
||||
Platform.ASSIST_SATELLITE,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.SELECT,
|
||||
Platform.SWITCH,
|
||||
)
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Binary sensor for VoIP."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import VoipConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .devices import VoIPDevice
|
||||
from .entity import VoIPEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: VoipConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up VoIP binary sensor entities."""
|
||||
domain_data = config_entry.runtime_data.domain_data
|
||||
|
||||
@callback
|
||||
def async_add_device(device: VoIPDevice) -> None:
|
||||
"""Add device."""
|
||||
async_add_entities([VoIPCallInProgress(device)])
|
||||
|
||||
domain_data.devices.async_add_new_device_listener(async_add_device)
|
||||
|
||||
async_add_entities([VoIPCallInProgress(device) for device in domain_data.devices])
|
||||
|
||||
|
||||
class VoIPCallInProgress(VoIPEntity, BinarySensorEntity):
|
||||
"""Entity to represent voip call is in progress."""
|
||||
|
||||
entity_description = BinarySensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
key="call_in_progress",
|
||||
translation_key="call_in_progress",
|
||||
)
|
||||
_attr_is_on = False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self.async_on_remove(
|
||||
self.voip_device.async_listen_update(self._is_active_changed)
|
||||
)
|
||||
|
||||
await super().async_added_to_hass()
|
||||
if TYPE_CHECKING:
|
||||
assert self.registry_entry is not None
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"assist_in_progress_deprecated_{self.registry_entry.id}",
|
||||
breaks_in_ha_version="2025.4",
|
||||
data={
|
||||
"entity_id": self.entity_id,
|
||||
"entity_uuid": self.registry_entry.id,
|
||||
"integration_name": "VoIP",
|
||||
},
|
||||
is_fixable=True,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="assist_in_progress_deprecated",
|
||||
translation_placeholders={
|
||||
"integration_name": "VoIP",
|
||||
},
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Remove issue."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if TYPE_CHECKING:
|
||||
assert self.registry_entry is not None
|
||||
ir.async_delete_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"assist_in_progress_deprecated_{self.registry_entry.id}",
|
||||
)
|
||||
|
||||
@callback
|
||||
def _is_active_changed(self, device: VoIPDevice) -> None:
|
||||
"""Call when active state changed."""
|
||||
self._attr_is_on = self.voip_device.is_active
|
||||
self.async_write_ha_state()
|
||||
@@ -1,20 +0,0 @@
|
||||
"""Repairs implementation for the VoIP integration."""
|
||||
|
||||
from homeassistant.components.assist_pipeline.repair_flows import ( # pylint: disable=home-assistant-component-root-import
|
||||
AssistInProgressDeprecatedRepairFlow,
|
||||
)
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
if issue_id.startswith("assist_in_progress_deprecated"):
|
||||
return AssistInProgressDeprecatedRepairFlow(data)
|
||||
# If VoIP adds confirm-only repairs in the future, this should be changed
|
||||
# to return a ConfirmRepairFlow instead of raising a ValueError
|
||||
raise ValueError(f"unknown repair {issue_id}")
|
||||
@@ -10,11 +10,6 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"call_in_progress": {
|
||||
"name": "Call in progress"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"pipeline": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]",
|
||||
@@ -48,18 +43,6 @@
|
||||
"message": "VoIP does not currently support non-TTS announcements"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"assist_in_progress_deprecated": {
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm_disable_entity": {
|
||||
"description": "[%key:component::assist_pipeline::issues::assist_in_progress_deprecated::fix_flow::step::confirm_disable_entity::description%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "[%key:component::assist_pipeline::issues::assist_in_progress_deprecated::title%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -124,6 +124,14 @@ MODEL_TO_CLASS_MAP = {
|
||||
MODEL_FAN_ZA5: FanZA5,
|
||||
}
|
||||
|
||||
# List of models requiring specific lazy_discover setting
|
||||
LAZY_DISCOVER_FOR_MODEL = {
|
||||
"zhimi.fan.za3": True,
|
||||
"zhimi.fan.za5": True,
|
||||
"zhimi.airpurifier.za1": True,
|
||||
"dmaker.fan.1c": True,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
@@ -307,13 +315,6 @@ async def async_create_miio_device_and_coordinator(
|
||||
update_method = _async_update_data_default
|
||||
coordinator_class: type[DataUpdateCoordinator[Any]] = DataUpdateCoordinator
|
||||
|
||||
# List of models requiring specific lazy_discover setting
|
||||
LAZY_DISCOVER_FOR_MODEL = {
|
||||
"zhimi.fan.za3": True,
|
||||
"zhimi.fan.za5": True,
|
||||
"zhimi.airpurifier.za1": True,
|
||||
"dmaker.fan.1c": True,
|
||||
}
|
||||
lazy_discover = LAZY_DISCOVER_FOR_MODEL.get(model, False)
|
||||
|
||||
if (
|
||||
|
||||
@@ -70,8 +70,8 @@ class YoLinkHomeMessageListener(MessageListener):
|
||||
return
|
||||
|
||||
device_coordinator.dev_online = True
|
||||
if (loraInfo := msg_data.get(ATTR_LORA_INFO)) is not None:
|
||||
device_coordinator.dev_net_type = loraInfo.get("devNetType")
|
||||
if (lora_info := msg_data.get(ATTR_LORA_INFO)) is not None:
|
||||
device_coordinator.dev_net_type = lora_info.get("devNetType")
|
||||
device_coordinator.async_set_updated_data(msg_data)
|
||||
# handling events
|
||||
if (
|
||||
|
||||
Generated
+2
@@ -404,6 +404,7 @@ FLOWS = {
|
||||
"lg_netcast",
|
||||
"lg_soundbar",
|
||||
"lg_thinq",
|
||||
"lg_tv_rs232",
|
||||
"libre_hardware_monitor",
|
||||
"lichess",
|
||||
"lidarr",
|
||||
@@ -535,6 +536,7 @@ FLOWS = {
|
||||
"opengarage",
|
||||
"openhome",
|
||||
"openrgb",
|
||||
"opensensemap",
|
||||
"opensky",
|
||||
"opentherm_gw",
|
||||
"openuv",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user