Compare commits

..

5 Commits

Author SHA1 Message Date
Franck Nijhof 7f0f30e916 Merge branch 'dev' into frenck-2026-0609 2026-05-26 11:26:40 +02:00
Franck Nijhof 5fa4e09127 Merge branch 'dev' into frenck-2026-0609 2026-05-21 17:19:54 +02:00
Franck Nijhof cf2ee1b32f Address copilot feedback 2026-05-21 14:36:02 +00:00
Franck Nijhof 93f60c5594 Address copilot feedback 2026-05-21 12:02:25 +00:00
Franck Nijhof a190117a9a Add pylint checker for missing flow form translations 2026-05-21 11:18:18 +00:00
496 changed files with 6101 additions and 43967 deletions
@@ -1,52 +0,0 @@
name: Cache and install APT packages
description: >-
Wraps awalsh128/cache-apt-pkgs-action with the workarounds Home Assistant CI
needs. Removes the conflicting Microsoft apt source before any apt run, and
points the dynamic linker at the host's multiarch lib subdirectories so
shared libraries that rely on update-alternatives or postinst-managed paths
(eg libblas, liblapack pulled in by ffmpeg) stay reachable since the upstream
action does not execute postinst scripts on cache restore.
inputs:
packages:
description: Space-delimited list of apt packages to install.
required: true
version:
description: Cache version. Bump to invalidate the cache.
required: false
default: "1"
execute_install_scripts:
description: >-
Pass-through to awalsh128/cache-apt-pkgs-action. Postinst scripts are not
actually cached by the upstream action, so this is largely a no-op today.
required: false
default: "false"
runs:
using: composite
steps:
- name: Remove conflicting Microsoft apt source
shell: bash
run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
- name: Install apt packages via cache
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
with:
packages: ${{ inputs.packages }}
version: ${{ inputs.version }}
execute_install_scripts: ${{ inputs.execute_install_scripts }}
- name: Refresh dynamic linker cache
shell: bash
run: |
# awalsh128/cache-apt-pkgs-action does not run postinst scripts on
# cache restore, so update-alternatives symlinks (eg the one libblas
# creates at /usr/lib/<multiarch>/libblas.so.3) are never produced.
# Add every /usr/lib/<multiarch> subdirectory that holds shared
# libraries to the ldconfig search path so the dynamic linker still
# finds them. Use dpkg-architecture to derive the host's multiarch
# tuple so this works on non-x86_64 runners too.
multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
find "/usr/lib/${multiarch}" -mindepth 2 -maxdepth 2 \
-name '*.so.*' -printf '%h\n' \
| sort -u \
| sudo tee /etc/ld.so.conf.d/zzz-cache-apt-extras.conf > /dev/null
sudo ldconfig
+204 -134
View File
@@ -60,7 +60,9 @@ 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_VERSION: 1
APT_CACHE_BASE: /home/runner/work/apt
APT_CACHE_DIR: /home/runner/work/apt/cache
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
SQLALCHEMY_WARN_20: 1
PYTHONASYNCIODEBUG: 1
HASS_CI: 1
@@ -84,6 +86,7 @@ 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 }}
@@ -113,6 +116,10 @@ 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
@@ -377,36 +384,65 @@ 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'
if: |
steps.cache-venv.outputs.cache-hit != 'true'
|| steps.cache-apt-check.outputs.cache-hit != 'true'
id: install-os-deps
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
libavcodec-dev
libavdevice-dev
libavfilter-dev
libavformat-dev
libavutil-dev
libswresample-dev
libswscale-dev
libudev-dev
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Read uv version from requirements.txt
if: steps.cache-venv.outputs.cache-hit != 'true'
id: read-uv-version
env:
APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
run: |
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
- name: Set up uv
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
mkdir -p ${APT_CACHE_DIR}
mkdir -p ${APT_LIST_CACHE_DIR}
fi
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils \
libavcodec-dev \
libavdevice-dev \
libavfilter-dev \
libavformat-dev \
libavutil-dev \
libswresample-dev \
libswscale-dev \
libudev-dev
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
sudo chmod -R 755 ${APT_CACHE_BASE}
fi
- name: Save apt cache
if: |
always()
&& steps.cache-apt-check.outputs.cache-hit != 'true'
&& steps.install-os-deps.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
version: ${{ steps.read-uv-version.outputs.version }}
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
id: create-venv
@@ -414,6 +450,8 @@ jobs:
python -m venv venv
. venv/bin/activate
python --version
pip install "$(grep '^uv' < requirements.txt)"
uv pip install -U "pip>=25.2"
uv pip install -r requirements.txt
uv pip install -r requirements_all.txt -r requirements_test.txt
uv pip install -e . --config-settings editable_mode=compat
@@ -468,16 +506,30 @@ 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
@@ -824,20 +876,32 @@ 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
@@ -853,49 +917,12 @@ jobs:
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore pytest test counts cache
id: cache-pytest-counts
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: pytest_test_counts.json
# Primary key is a sentinel; restore-keys pick the most recent
# prefix match since the real (content-addressed) key isn't
# known until split_tests.py runs below.
key: >-
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}-restore-sentinel
restore-keys: |
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }}-
- name: Run split_tests.py
env:
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
run: |
. venv/bin/activate
python -m script.split_tests \
--cache pytest_test_counts.json \
${TEST_GROUP_COUNT} tests
- name: Hash pytest test counts cache
id: cache-pytest-counts-hash
run: |
echo "hash=$(sha256sum pytest_test_counts.json | cut -d' ' -f1)" \
>> "$GITHUB_OUTPUT"
- name: Save pytest test counts cache
# Content-addressed key: identical content reuses the same entry.
# Skip the save when the restore already matched that hash.
if: >-
!endsWith(
steps.cache-pytest-counts.outputs.cache-matched-key,
steps.cache-pytest-counts-hash.outputs.hash
)
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: pytest_test_counts.json
key: >-
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}-${{
steps.cache-pytest-counts-hash.outputs.hash }}
python -m script.split_tests ${TEST_GROUP_COUNT} tests
- name: Upload pytest_buckets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -925,21 +952,33 @@ 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
@@ -1066,22 +1105,34 @@ 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
@@ -1215,29 +1266,36 @@ 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
@@ -1391,21 +1449,33 @@ 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
+4 -4
View File
@@ -55,11 +55,11 @@ jobs:
- name: Generate app token
id: token
# Pinned to a specific version of the action for security reasons
# v3.2.0
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
# v1.7.0
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
with:
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
# The 90 day stale policy for issues
# Used for:
-3
View File
@@ -337,7 +337,6 @@ 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.*
@@ -429,7 +428,6 @@ homeassistant.components.otp.*
homeassistant.components.ouman_eh_800.*
homeassistant.components.overkiz.*
homeassistant.components.overseerr.*
homeassistant.components.ovhcloud_ai_endpoints.*
homeassistant.components.p1_monitor.*
homeassistant.components.paj_gps.*
homeassistant.components.panel_custom.*
@@ -567,7 +565,6 @@ homeassistant.components.technove.*
homeassistant.components.tedee.*
homeassistant.components.telegram_bot.*
homeassistant.components.teleinfo.*
homeassistant.components.teltonika.*
homeassistant.components.teslemetry.*
homeassistant.components.text.*
homeassistant.components.thethingsnetwork.*
Generated
-8
View File
@@ -987,8 +987,6 @@ 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
@@ -1292,8 +1290,6 @@ 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
@@ -1321,8 +1317,6 @@ CLAUDE.md @home-assistant/core
/tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek @AmGarera
/tests/components/overseerr/ @joostlek @AmGarera
/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon
/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -2062,8 +2056,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/yi/ @bachya
/homeassistant/components/yolink/ @matrixd2
/tests/components/yolink/ @matrixd2
/homeassistant/components/yoto/ @cdnninja @piitaya
/tests/components/yoto/ @cdnninja @piitaya
/homeassistant/components/youless/ @gjong
/tests/components/youless/ @gjong
/homeassistant/components/youtube/ @joostlek
@@ -1,13 +1,9 @@
"""Alexa Devices integration."""
import asyncio
import contextlib
from homeassistant.const import CONF_COUNTRY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
@@ -16,8 +12,6 @@ from .services import async_setup_services
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.EVENT,
Platform.MEDIA_PLAYER,
Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
@@ -40,28 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
await coordinator.async_config_entry_first_refresh()
await coordinator.sync_history_state()
await coordinator.sync_media_state()
async def _on_http2_reauth_required() -> None:
entry.async_start_reauth(hass)
async def _cancel_http2() -> None:
http2_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await http2_task
alexa_httpx_client = httpx_client.get_async_client(
hass,
alpn_protocols=SSL_ALPN_HTTP11_HTTP2,
)
http2_task = await coordinator.api.start_http2_processing(
alexa_httpx_client, on_reauth_required=_on_http2_reauth_required
)
entry.async_on_unload(_cancel_http2)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -8,18 +8,13 @@ from aioamazondevices.exceptions import (
CannotConnect,
CannotRetrieveData,
)
from aioamazondevices.structures import (
AmazonDevice,
AmazonMediaState,
AmazonVocalRecord,
AmazonVolumeState,
)
from aioamazondevices.structures import AmazonDevice
from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -78,18 +73,6 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
if routine.domain == Platform.BUTTON
}
self._vocal_records: dict[str, AmazonVocalRecord] = {}
self.api.on_history_event.append(self.history_state_event_handler)
self.api.on_history_event.freeze()
self._volume_states: dict[str, AmazonVolumeState] = {}
self.api.on_volume_state_event.append(self.volume_state_event_handler)
self.api.on_volume_state_event.freeze()
self._media_states: dict[str, AmazonMediaState] = {}
self.api.on_media_state_event.append(self.media_state_event_handler)
self.api.on_media_state_event.freeze()
async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data."""
try:
@@ -166,66 +149,3 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
)
if entity_id:
entity_registry.async_remove(entity_id)
async def sync_history_state(self) -> None:
"""Sync history state."""
try:
self._vocal_records = await self.api.sync_history_state()
except CannotAuthenticate as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(e)},
) from e
except CannotConnect as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(e)},
) from e
except BaseException as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(e)},
) from e
async def history_state_event_handler(
self, vocal_records: dict[str, AmazonVocalRecord]
) -> None:
"""Handle pushed vocal record events."""
self._vocal_records = {**self._vocal_records, **vocal_records}
self.async_update_listeners()
@property
def vocal_records(self) -> dict[str, AmazonVocalRecord]:
"""Vocal records of devices."""
return self._vocal_records
async def sync_media_state(self) -> None:
"""Sync media state."""
await self.api.sync_media_state()
async def media_state_event_handler(
self, media_state: dict[str, AmazonMediaState]
) -> None:
"""Handle pushed media state changed events."""
self._media_states = media_state
self.async_update_listeners()
@property
def media_states(self) -> dict[str, AmazonMediaState]:
"""Media state of devices."""
return self._media_states
async def volume_state_event_handler(
self, volume_states: dict[str, AmazonVolumeState]
) -> None:
"""Handle pushed volume change events."""
self._volume_states = volume_states
self.async_update_listeners()
@property
def volume_states(self) -> dict[str, AmazonVolumeState]:
"""Volumes of devices."""
return self._volume_states
@@ -1,86 +0,0 @@
"""Support for events."""
from typing import Final
from homeassistant.components.event import EventEntity, EventEntityDescription
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
EVENTS: Final = {
EventEntityDescription(
key="voice_event",
translation_key="voice_event",
),
}
EVENT_TYPE = "triggered"
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices events based on a config entry."""
coordinator = entry.runtime_data
known_devices: set[str] = set()
def _check_device() -> None:
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AlexaVoiceEvent(coordinator, serial_num, event_desc)
for event_desc in EVENTS
for serial_num in new_devices
)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
class AlexaVoiceEvent(AmazonEntity, EventEntity):
"""Representation of an Alexa voice event."""
_attr_event_types = [EVENT_TYPE]
coordinator: AmazonDevicesCoordinator
_last_seen_timestamp: int | None = None
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if not (
vocal_record := self.coordinator.vocal_records.get(
self.device.serial_number
)
):
_LOGGER.debug(
"No vocal record found for device %s [%s]",
self.device.account_name,
self.device.serial_number,
)
return
if vocal_record.timestamp == self._last_seen_timestamp:
return
self._last_seen_timestamp = vocal_record.timestamp
self._trigger_event(
EVENT_TYPE,
{
"intent": vocal_record.intent,
"voice_command": vocal_record.title,
"voice_reply": vocal_record.sub_title,
},
)
self.async_write_ha_state()
@@ -1,10 +1,5 @@
{
"entity": {
"event": {
"voice_event": {
"default": "mdi:chat-processing"
}
},
"sensor": {
"voc_index": {
"default": "mdi:molecule"
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.8.0"]
"requirements": ["aioamazondevices==13.7.0"]
}
@@ -1,294 +0,0 @@
"""Media player platform for Alexa Devices."""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Final
from aioamazondevices.structures import (
AmazonMediaControls,
AmazonMediaState,
AmazonVolumeState,
)
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityDescription,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
STANDARD_SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PLAY_MEDIA
)
@dataclass(frozen=True, kw_only=True)
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
"""Describes an Alexa Devices media player entity."""
MEDIA_PLAYERS: Final = (
AmazonDevicesMediaPlayerEntityDescription(
key="media",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices media player entities from a config entry."""
coordinator = entry.runtime_data
known_devices: set[str] = set()
def _check_device() -> None:
"""Add entities for newly discovered devices."""
new_entities: list[AlexaDevicesMediaPlayer] = []
for serial_num, device in coordinator.data.items():
if serial_num in known_devices or not device.media_player_supported:
continue
known_devices.add(serial_num)
new_entities.extend(
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
for description in MEDIA_PLAYERS
)
if new_entities:
async_add_entities(new_entities)
remove_listener = coordinator.async_add_listener(_check_device)
entry.async_on_unload(remove_listener)
_check_device()
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
"""Representation of an Alexa device media player."""
entity_description: AmazonDevicesMediaPlayerEntityDescription
_attr_name = None # Uses the device name
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_volume_step = 0.05
def __init__(
self,
coordinator: AmazonDevicesCoordinator,
serial_num: str,
description: AmazonDevicesMediaPlayerEntityDescription,
) -> None:
"""Initialize."""
self._prev_volume: int | None = None
super().__init__(coordinator, serial_num, description)
@property
def media_state(self) -> AmazonMediaState | None:
"""Return the media state relating to device."""
if not self.coordinator or not self.coordinator.media_states:
return None
return self.coordinator.media_states.get(self._serial_num)
@property
def volume_state(self) -> AmazonVolumeState | None:
"""Volume settings for device."""
if not self.coordinator or not self.coordinator.volume_states:
return None
return self.coordinator.volume_states.get(self._serial_num)
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Return dynamically supported features based on current media."""
features = STANDARD_SUPPORTED_FEATURES
if self.media_state is None:
return features
if self.media_state.pause_enabled:
features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE
if self.media_state.next_enabled:
features |= MediaPlayerEntityFeature.NEXT_TRACK
if self.media_state.previous_enabled:
features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
return features
@property
def state(self) -> MediaPlayerState | None:
"""Return the current state of the player."""
if not self.media_state:
return MediaPlayerState.IDLE
if self.media_state.player_state == "PLAYING":
return MediaPlayerState.PLAYING
if self.media_state.player_state == "PAUSED":
return MediaPlayerState.PAUSED
return MediaPlayerState.IDLE
@property
def volume_level(self) -> float | None:
"""Return the volume level (0.0 to 1.0)."""
if not self.volume_state or self.volume_state.volume is None:
return None
return self.volume_state.volume / 100
@property
def is_volume_muted(self) -> bool | None:
"""Return True if the volume is muted."""
if not self.volume_state:
return None
return self.volume_state.volume == 0
@property
def media_title(self) -> str | None:
"""Track title."""
if not self.media_state:
return None
return self.media_state.now_playing_title
@property
def media_artist(self) -> str | None:
"""Artist name."""
if not self.media_state:
return None
return self.media_state.now_playing_line1
@property
def media_album_name(self) -> str | None:
"""Album name."""
if not self.media_state:
return None
return self.media_state.now_playing_line2
@property
def media_image_url(self) -> str | None:
"""Album art URL."""
if not self.media_state:
return None
return self.media_state.now_playing_url
@property
def media_duration(self) -> int | None:
"""Duration in seconds."""
if not self.media_state:
return None
return self.media_state.media_length
@property
def media_position(self) -> int | None:
"""Current playback position in seconds."""
if not self.media_state:
return None
return self.media_state.media_position
@property
def media_position_updated_at(self) -> datetime | None:
"""When media_position was last updated — HA uses this to interpolate the progress bar."""
if not self.media_state:
return None
return self.media_state.media_position_updated_at
@property
def media_content_type(self) -> MediaType | None:
"""Content type — tells HA what kind of media is playing."""
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
return MediaType.MUSIC
return None
async def async_play_media(
self,
media_type: MediaType | str,
media_id: str,
enqueue: MediaPlayerEnqueue | None = None,
announce: bool | None = None,
**kwargs: Any,
) -> None:
"""Play a piece of media."""
await self.async_call_alexa_music(media_id, media_type)
@alexa_api_call
async def async_call_alexa_music(
self, search_phrase: str, provider_id: str
) -> None:
"""Call alexa music."""
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
@alexa_api_call
async def async_set_device_volume(self, volume: int) -> None:
"""Set the device volume."""
_LOGGER.debug(
"Setting volume for %s to %s%%",
self.device.serial_number,
volume,
)
await self.coordinator.api.set_device_volume(self.device, volume)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level (0.0 to 1.0)."""
device_volume = round(volume * 100)
await self.async_set_device_volume(device_volume)
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or un-mute the volume."""
# Whilst you can mute a device by asking it there appears to be
# no way to do this programmatically so set volume to 0
if not self.volume_state or self.volume_state.volume is None:
return
if mute:
self._prev_volume = self.volume_state.volume
target_volume = 0
else:
if self._prev_volume is None:
return
target_volume = self._prev_volume
await self.async_set_volume_level(target_volume / 100)
@alexa_api_call
async def _send_media_command(self, command: AmazonMediaControls) -> None:
_LOGGER.debug(
"Sending media command '%s' to %s", command, self.device.serial_number
)
await self.coordinator.api.send_media_command(self.device, command)
async def async_media_stop(self) -> None:
"""Send stop command."""
await self._send_media_command(AmazonMediaControls.Stop)
async def async_media_pause(self) -> None:
"""Send pause command."""
await self._send_media_command(AmazonMediaControls.Pause)
async def async_media_play(self) -> None:
"""Send play command."""
await self._send_media_command(AmazonMediaControls.Play)
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self._send_media_command(AmazonMediaControls.Next)
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
await self._send_media_command(AmazonMediaControls.Previous)
@@ -58,18 +58,6 @@
}
},
"entity": {
"event": {
"voice_event": {
"name": "Voice event",
"state_attributes": {
"event_type": {
"state": {
"triggered": "Triggered"
}
}
}
}
},
"notify": {
"announce": {
"name": "Announce"
@@ -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
temperature_key = "temperature"
CONF_TEMPERATURE = "temperature"
for subentry in entry.subentries.values():
data = subentry.data.copy()
if temperature_key not in data:
if CONF_TEMPERATURE not in data:
continue
data.pop(temperature_key, None)
data.pop(CONF_TEMPERATURE, None)
hass.config_entries.async_update_subentry(entry, subentry, data=data)
hass.config_entries.async_update_entry(entry, minor_version=4)
+24
View File
@@ -7,3 +7,27 @@ 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",
}
+121 -19
View File
@@ -1,10 +1,11 @@
"""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,
@@ -23,9 +24,11 @@ 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 LAST_S_TEST
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
from .entity import APCUPSdEntity
@@ -33,20 +36,6 @@ 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",
@@ -60,6 +49,18 @@ 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",
@@ -99,6 +100,12 @@ 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",
@@ -125,11 +132,23 @@ 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",
@@ -245,6 +264,12 @@ 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",
@@ -333,6 +358,12 @@ 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",
@@ -373,6 +404,18 @@ 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",
@@ -438,10 +481,9 @@ async def async_setup_entry(
# as unknown initially.
#
# We also sort the resources to ensure the order of entities
# created is deterministic
# created is deterministic since "APCMODEL" and "MODEL"
# resources map to the same "Model" name.
for resource in sorted(available_resources | {LAST_S_TEST}):
if resource in IGNORED_SENSORS:
continue
if resource not in SENSORS:
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
continue
@@ -519,3 +561,63 @@ 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,5 +241,19 @@
"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"
}
}
}
+14 -14
View File
@@ -49,20 +49,6 @@ 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."""
@@ -201,6 +187,20 @@ 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
+4 -44
View File
@@ -1,6 +1,5 @@
"""Light platform for Avea."""
from collections.abc import Callable
from contextlib import suppress
import logging
from typing import Any
@@ -20,7 +19,6 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -29,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import color as color_util
from . import AveaConfigEntry
from .const import DOMAIN, INTEGRATION_TITLE, MODEL, UNKNOWN_NAME
from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
_LOGGER = logging.getLogger(__name__)
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
@@ -44,13 +42,6 @@ def _normalize_name(name: str | None) -> str | None:
return name
def _read_device_info_value(read: Callable[[], str | None]) -> str | None:
"""Read a device information value from an Avea bulb."""
with suppress(*UPDATE_EXCEPTIONS):
return _normalize_name(read())
return None
def _ha_brightness_to_avea(brightness: int) -> int:
"""Convert Home Assistant brightness to Avea brightness."""
return round((brightness / 255) * AVEA_MAX_BRIGHTNESS)
@@ -105,8 +96,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Avea light platform."""
async_add_entities(
[AveaLight(entry.runtime_data, entry.data[CONF_ADDRESS])],
update_before_add=True,
[AveaLight(entry.runtime_data, entry.title)], update_before_add=True
)
@@ -190,42 +180,14 @@ class AveaLight(LightEntity):
"""Representation of an Avea."""
_attr_color_mode = ColorMode.HS
_attr_has_entity_name = True
_attr_name = None
_attr_supported_color_modes = {ColorMode.HS}
def __init__(self, light: avea.Bulb, address: str) -> None:
def __init__(self, light: avea.Bulb, entry_title: str) -> None:
"""Initialize an AveaLight."""
self._light = light
self._attr_unique_id = address
self._attr_name = entry_title
self._attr_brightness = light.brightness
self._last_brightness = 255
self._device_info_updated = False
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_BLUETOOTH, address)},
model=MODEL,
)
def _update_device_info(self) -> None:
"""Fetch device information from the Avea bulb."""
device_info = self._attr_device_info
assert device_info is not None
manufacturer = _read_device_info_value(self._light.get_manufacturer_name)
hardware_revision = _read_device_info_value(self._light.get_hardware_revision)
firmware_version = _read_device_info_value(self._light.get_fw_version)
serial_number = _read_device_info_value(self._light.get_serial_number)
if manufacturer:
device_info["manufacturer"] = manufacturer
if hardware_revision:
device_info["hw_version"] = hardware_revision
if firmware_version:
device_info["sw_version"] = firmware_version
if serial_number:
device_info["serial_number"] = serial_number
self._device_info_updated = True
def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
@@ -252,8 +214,6 @@ class AveaLight(LightEntity):
connected = self._light.connect()
try:
if not self._device_info_updated:
self._update_device_info()
brightness = self._light.get_brightness()
rgb_color = self._light.get_rgb()
finally:
@@ -32,7 +32,6 @@ PLATFORMS = [
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
PARALLEL_UPDATES = 0
+3 -31
View File
@@ -3,7 +3,7 @@
from typing import Any
import blebox_uniapi.cover
from blebox_uniapi.cover import BleboxCoverState, UnifiedCoverType
from blebox_uniapi.cover import BleboxCoverState
from homeassistant.components.cover import (
ATTR_POSITION,
@@ -25,19 +25,6 @@ BLEBOX_TO_COVER_DEVICE_CLASSES = {
"shutter": CoverDeviceClass.SHUTTER,
}
UNIFIED_COVER_TYPE_TO_DEVICE_CLASS = {
UnifiedCoverType.AWNING: CoverDeviceClass.AWNING,
UnifiedCoverType.BLIND: CoverDeviceClass.BLIND,
UnifiedCoverType.CURTAIN: CoverDeviceClass.CURTAIN,
UnifiedCoverType.DAMPER: CoverDeviceClass.DAMPER,
UnifiedCoverType.DOOR: CoverDeviceClass.DOOR,
UnifiedCoverType.GARAGE: CoverDeviceClass.GARAGE,
UnifiedCoverType.GATE: CoverDeviceClass.GATE,
UnifiedCoverType.SHADE: CoverDeviceClass.SHADE,
UnifiedCoverType.SHUTTER: CoverDeviceClass.SHUTTER,
UnifiedCoverType.WINDOW: CoverDeviceClass.WINDOW,
}
BLEBOX_TO_HASS_COVER_STATES = {
None: None,
# all blebox covers
@@ -72,6 +59,7 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
"""Initialize a BleBox cover feature."""
super().__init__(feature)
self._attr_device_class = BLEBOX_TO_COVER_DEVICE_CLASSES[feature.device_class]
self._attr_supported_features = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
)
@@ -88,21 +76,6 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
| CoverEntityFeature.CLOSE_TILT
)
if feature.tilt_only:
self._attr_supported_features &= ~(
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.SET_POSITION
| CoverEntityFeature.STOP
)
@property
def device_class(self) -> CoverDeviceClass | None:
"""Return the device class based on cover type when available."""
if (cover_type := self._feature.cover_type) is not None:
return UNIFIED_COVER_TYPE_TO_DEVICE_CLASS[cover_type]
return BLEBOX_TO_COVER_DEVICE_CLASSES[self._feature.device_class]
@property
def current_cover_position(self) -> int | None:
"""Return the current cover position."""
@@ -145,8 +118,7 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Fully open the cover tilt."""
position = 50 if self._feature.is_tilt_180 else 0
await self._feature.async_set_tilt_position(position)
await self._feature.async_set_tilt_position(0)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Fully close the cover tilt."""
-141
View File
@@ -1,141 +0,0 @@
"""BleBox update entities implementation."""
from datetime import timedelta
from typing import Any, Final
from blebox_uniapi.error import ConnectionError as BleBoxConnectionError, Error
import blebox_uniapi.update
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
SCAN_INTERVAL = timedelta(hours=1)
_POLL_INTERVAL_SECONDS: Final = 10
_MAX_POLL_ATTEMPTS: Final = 30
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BleBoxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox update entry."""
entities = [
BleBoxUpdateEntity(feature)
for feature in config_entry.runtime_data.features.get("updates", [])
]
async_add_entities(entities, True)
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
"""Representation of BleBox updates."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
)
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
"""Initialize the update entity."""
super().__init__(feature)
self._in_progress_old_version: str | None = None
self._poll_cancel: CALLBACK_TYPE | None = None
self._poll_attempts: int = 0
@property
def in_progress(self) -> bool:
"""Return True while the device hasn't yet rebooted to the new firmware."""
return (
self._in_progress_old_version is not None
and self._in_progress_old_version == self._feature.installed_version
)
def _sync_sw_version(self) -> None:
"""Sync installed firmware version to the device registry."""
if self.device_entry:
dr.async_get(self.hass).async_update_device(
self.device_entry.id,
sw_version=self._feature.installed_version,
)
async def async_update(self) -> None:
"""Update state and refresh sw_version in device registry."""
try:
await self._feature.async_update()
except Error as ex:
raise HomeAssistantError(ex) from ex
self._sync_sw_version()
@property
def installed_version(self) -> str | None:
"""Version installed and in use."""
return self._feature.installed_version
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
return self._feature.latest_version
def _cancel_poll(self) -> None:
if self._poll_cancel is not None:
self._poll_cancel()
self._poll_cancel = None
def _reset_progress(self) -> None:
self._in_progress_old_version = None
self._poll_attempts = 0
self.async_write_ha_state()
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
self._cancel_poll()
self._in_progress_old_version = self._feature.installed_version
self._poll_attempts = 0
self.async_write_ha_state()
try:
await self._feature.async_install()
except Error as ex:
self._reset_progress()
raise HomeAssistantError(ex) from ex
self._poll_cancel = async_call_later(
self.hass, _POLL_INTERVAL_SECONDS, self._poll_until_updated
)
async def async_will_remove_from_hass(self) -> None:
"""Cancel any pending poll timer when the entity is removed."""
self._cancel_poll()
async def _poll_until_updated(self, _now: Any) -> None:
"""Poll device until the installed version changes after OTA reboot."""
self._poll_cancel = None
self._poll_attempts += 1
try:
await self._feature.async_update()
except BleBoxConnectionError:
pass
except Error:
self._reset_progress()
return
else:
self._sync_sw_version()
if self.in_progress and self._poll_attempts < _MAX_POLL_ATTEMPTS:
self._poll_cancel = async_call_later(
self.hass, _POLL_INTERVAL_SECONDS, self._poll_until_updated
)
else:
self._reset_progress()
@@ -173,6 +173,7 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
config_entry = self._get_reconfigure_entry()
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
-1
View File
@@ -17,7 +17,6 @@ BTHOME_BLE_EVENT: Final = "bthome_ble_event"
EVENT_CLASS_BUTTON: Final = "button"
EVENT_CLASS_DIMMER: Final = "dimmer"
EVENT_CLASS_COMMAND: Final = "command"
CONF_EVENT_CLASS: Final = "event_class"
CONF_EVENT_PROPERTIES: Final = "event_properties"
@@ -28,7 +28,6 @@ from .const import (
DOMAIN,
EVENT_CLASS,
EVENT_CLASS_BUTTON,
EVENT_CLASS_COMMAND,
EVENT_CLASS_DIMMER,
EVENT_TYPE,
)
@@ -44,7 +43,6 @@ EVENT_TYPES_BY_EVENT_CLASS = {
"hold_press",
},
EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"},
EVENT_CLASS_COMMAND: {"off", "on", "toggle", "step_up", "step_down"},
}
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
-6
View File
@@ -16,7 +16,6 @@ from . import format_discovered_event_class, format_event_dispatcher_name
from .const import (
DOMAIN,
EVENT_CLASS_BUTTON,
EVENT_CLASS_COMMAND,
EVENT_CLASS_DIMMER,
EVENT_PROPERTIES,
EVENT_TYPE,
@@ -44,11 +43,6 @@ DESCRIPTIONS_BY_EVENT_CLASS = {
translation_key="dimmer",
event_types=["rotate_left", "rotate_right"],
),
EVENT_CLASS_COMMAND: EventEntityDescription(
key=EVENT_CLASS_COMMAND,
translation_key="command",
event_types=["off", "on", "toggle", "step_up", "step_down"],
),
}
@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.23.2"]
"requirements": ["bthome-ble==3.17.0"]
}
-12
View File
@@ -192,12 +192,6 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
),
# Light level (-)
(BTHomeExtendedSensorDeviceClass.LIGHT_LEVEL, None): SensorEntityDescription(
key=str(BTHomeExtendedSensorDeviceClass.LIGHT_LEVEL),
state_class=SensorStateClass.MEASUREMENT,
translation_key="light_level",
),
# Mass sensor (kg)
(BTHomeSensorDeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription(
key=f"{BTHomeSensorDeviceClass.MASS}_{Units.MASS_KILOGRAMS}",
@@ -293,12 +287,6 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
translation_key="rotational_speed",
),
# Settings revision (-)
(BTHomeExtendedSensorDeviceClass.SETTINGS_REVISION, None): SensorEntityDescription(
key=str(BTHomeExtendedSensorDeviceClass.SETTINGS_REVISION),
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="settings_revision",
),
# Signal Strength (RSSI) (dB)
(
BTHomeSensorDeviceClass.SIGNAL_STRENGTH,
@@ -36,19 +36,13 @@
"long_double_press": "Long Double Press",
"long_press": "Long Press",
"long_triple_press": "Long Triple Press",
"off": "Off",
"on": "On",
"press": "Press",
"rotate_left": "Rotate Left",
"rotate_right": "Rotate Right",
"step_down": "Step Down",
"step_up": "Step Up",
"toggle": "Toggle",
"triple_press": "Triple Press"
},
"trigger_type": {
"button": "Button \"{subtype}\"",
"command": "Command \"{subtype}\"",
"dimmer": "Dimmer \"{subtype}\""
}
},
@@ -74,19 +68,6 @@
}
}
},
"command": {
"state_attributes": {
"event_type": {
"state": {
"off": "Off",
"on": "On",
"step_down": "Step down",
"step_up": "Step up",
"toggle": "Toggle"
}
}
}
},
"dimmer": {
"state_attributes": {
"event_type": {
@@ -117,9 +98,6 @@
"gyroscope": {
"name": "Gyroscope"
},
"light_level": {
"name": "Light level"
},
"packet_id": {
"name": "Packet ID"
},
@@ -132,9 +110,6 @@
"rotational_speed": {
"name": "Rotational speed"
},
"settings_revision": {
"name": "Settings revision"
},
"text": {
"name": "Text"
},
+21 -14
View File
@@ -32,16 +32,8 @@ OPTIONS_SCHEMA = KNOWN_HOSTS_SCHEMA.extend(
vol.Required(CONF_MORE_OPTIONS): section(
vol.Schema(
{
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
),
),
vol.Optional(CONF_UUID): str,
vol.Optional(CONF_IGNORE_CEC): str,
}
),
SectionConfig(collapsed=True),
@@ -117,11 +109,13 @@ class CastOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the Google Cast options."""
if user_input is not None:
ignore_cec = _trim_items(
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, [])
ignore_cec = _string_to_list(
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, "")
)
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
wanted_uuid = _trim_items(user_input[CONF_MORE_OPTIONS].get(CONF_UUID, []))
wanted_uuid = _string_to_list(
user_input[CONF_MORE_OPTIONS].get(CONF_UUID, "")
)
updated_config = dict(self.config_entry.data)
updated_config[CONF_IGNORE_CEC] = ignore_cec
updated_config[CONF_KNOWN_HOSTS] = known_hosts
@@ -138,7 +132,9 @@ 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] = self.config_entry.data[key]
suggested[CONF_MORE_OPTIONS][key] = _list_to_string(
self.config_entry.data[key]
)
return self.async_show_form(
step_id="init",
@@ -147,5 +143,16 @@ class CastOptionsFlowHandler(OptionsFlow):
)
def _list_to_string(items: list[str]) -> str:
comma_separated_string = ""
if items:
comma_separated_string = ",".join(items)
return comma_separated_string
def _string_to_list(string: str) -> list[str]:
return [x.strip() for x in string.split(",") if x.strip()]
def _trim_items(items: list[str]) -> list[str]:
return [x.strip() for x in items if x.strip()]
@@ -1,57 +0,0 @@
"""Diagnostics for the cert_expiry integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from .coordinator import CertExpiryConfigEntry
TO_REDACT = {CONF_HOST, "name", "title", "unique_id"}
async def async_get_config_entry_diagnostics(
_hass: HomeAssistant,
entry: CertExpiryConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
entry_diagnostics = entry.as_dict()
coordinator = getattr(entry, "runtime_data", None)
coordinator_diagnostics: dict[str, Any] = {
"host": None,
"port": None,
"name": None,
"expiry_datetime": None,
"is_cert_valid": None,
"cert_error": None,
"last_update_success": None,
}
if coordinator is not None:
expiry = coordinator.data.isoformat() if coordinator.data else None
cert_error = (
(
f"{type(coordinator.cert_error).__module__}."
f"{type(coordinator.cert_error).__qualname__}"
)
if coordinator.cert_error
else None
)
coordinator_diagnostics = {
"host": coordinator.host,
"port": coordinator.port,
"name": coordinator.name,
"expiry_datetime": expiry,
"is_cert_valid": coordinator.is_cert_valid,
"cert_error": cert_error,
"last_update_success": coordinator.last_update_success,
}
return {
"entry": async_redact_data(entry_diagnostics, TO_REDACT),
"coordinator": async_redact_data(coordinator_diagnostics, TO_REDACT),
}
@@ -1,81 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling:
status: done
comment: Certificates are checked every 12 hours via DataUpdateCoordinator.
brands: done
common-modules: done
config-flow-test-coverage:
status: done
comment: test_abort_on_socket_failed can be parametrized and should end in CREATE_ENTRY to test flow recovery.
config-flow: done
dependency-transparency:
status: exempt
comment: Integration has no external library dependencies.
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: todo
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: Config flow only collects host/port; the integration does not authenticate.
test-coverage:
status: todo
comment: Consider creating a mock_config_entry fixture and use that throughout tests.
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Integration supports a single device per config entry.
entity-category:
status: todo
comment: Extra state attributes (is_valid, error) should be moved to separate entities in the future.
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices:
status: exempt
comment: Integration supports a single device per config entry.
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
@@ -16,10 +16,6 @@
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the server to monitor.",
"port": "The port to connect to on the server."
},
"title": "Reconfigure the certificate to test"
},
"user": {
@@ -28,10 +24,6 @@
"name": "The name of the certificate",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the server to monitor.",
"port": "The port to connect to on the server."
},
"title": "Define the certificate to test"
}
}
@@ -5,5 +5,3 @@ ATTR_URL = "color_extract_url"
DOMAIN = "color_extractor"
DEFAULT_NAME = "Color extractor"
SERVICE_GET_COLOR = "get_color"
@@ -1,8 +1,5 @@
{
"services": {
"get_color": {
"service": "mdi:select-color"
},
"turn_on": {
"service": "mdi:lightbulb-on"
}
@@ -3,7 +3,6 @@
import asyncio
import io
import logging
from typing import Any
import aiohttp
from colorthief import ColorThief
@@ -16,16 +15,15 @@ from homeassistant.components.light import (
LIGHT_TURN_ON_SCHEMA,
)
from homeassistant.const import SERVICE_TURN_ON
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import aiohttp_client, config_validation as cv
from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_GET_COLOR
from .const import ATTR_PATH, ATTR_URL, DOMAIN
_LOGGER = logging.getLogger(__name__)
# Extend the existing light.turn_on service schema
TURN_ON_SERVICE_SCHEMA = vol.All(
SERVICE_SCHEMA = vol.All(
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
cv.make_entity_service_schema(
{
@@ -36,14 +34,6 @@ TURN_ON_SERVICE_SCHEMA = vol.All(
),
)
GET_COLOR_SERVICE_SCHEMA = vol.All(
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
{
vol.Exclusive(ATTR_PATH, "color_extractor"): cv.isfile,
vol.Exclusive(ATTR_URL, "color_extractor"): cv.url,
},
)
def _get_file(file_path: str) -> str:
"""Get a PIL acceptable input file reference.
@@ -155,50 +145,6 @@ async def async_handle_service(service_call: ServiceCall) -> None:
)
async def async_handle_get_color(
service_call: ServiceCall,
) -> dict[str, Any]:
"""Handle get_color service call."""
service_data = dict(service_call.data)
try:
if ATTR_URL in service_data:
image_type = "URL"
image_reference = service_data.pop(ATTR_URL)
color = await _async_extract_color_from_url(
service_call.hass, image_reference
)
elif ATTR_PATH in service_data:
image_type = "file path"
image_reference = service_data.pop(ATTR_PATH)
color = await service_call.hass.async_add_executor_job(
_extract_color_from_path, service_call.hass, image_reference
)
except UnidentifiedImageError as ex:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_image",
translation_placeholders={
"image_type": image_type,
"image_reference": image_reference,
},
) from ex
if color is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_image",
translation_placeholders={
"image_type": image_type,
"image_reference": image_reference,
},
)
return {"color": color}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the services."""
@@ -207,13 +153,5 @@ def async_setup_services(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_TURN_ON,
async_handle_service,
schema=TURN_ON_SERVICE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_COLOR,
async_handle_get_color,
schema=GET_COLOR_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
schema=SERVICE_SCHEMA,
)
@@ -11,13 +11,3 @@ turn_on:
example: /opt/images/logo.png
selector:
text:
get_color:
fields:
color_extract_url:
example: https://www.example.com/images/logo.png
selector:
text:
color_extract_path:
example: /opt/images/logo.png
selector:
text:
@@ -6,26 +6,7 @@
}
}
},
"exceptions": {
"invalid_image": {
"message": "Bad image {image_reference} from {image_type} provided, are you sure it's an image?"
}
},
"services": {
"get_color": {
"description": "Gets the predominant RGB color found in the image provided by URL or file path.",
"fields": {
"color_extract_path": {
"description": "The full system path to the image we want to extract RGB values from. Must be allowed in allowlist_external_dirs.",
"name": "[%key:common::config_flow::data::path%]"
},
"color_extract_url": {
"description": "The URL of the image we want to extract RGB values from. Must be allowed in allowlist_external_urls.",
"name": "[%key:common::config_flow::data::url%]"
}
},
"name": "Get predominant color"
},
"turn_on": {
"description": "Sets the light RGB to the predominant color found in the image provided by URL or file path.",
"fields": {
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"quality_scale": "silver",
"requirements": ["data-grand-lyon-ha==0.7.0"]
}
@@ -49,15 +49,13 @@ rules:
status: exempt
comment: This is a service integration; there are no discoverable devices.
docs-data-update: done
docs-examples: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: This is a service integration; devices are added and removed manually by the user.
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -68,9 +66,7 @@ rules:
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
stale-devices:
status: exempt
comment: This is a service integration; devices are added and removed manually by the user.
stale-devices: done
# Platinum
async-dependency: done
@@ -3,14 +3,23 @@
import asyncio
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_HOME
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .config_entry import ( # noqa: F401
DATA_COMPONENT,
BaseScannerEntity,
BaseTrackerEntity,
ScannerEntity,
ScannerEntityDescription,
TrackerEntity,
TrackerEntityDescription,
async_setup_entry,
async_unload_entry,
)
from .const import ( # noqa: F401
ATTR_ATTRIBUTES,
ATTR_BATTERY,
@@ -36,14 +45,6 @@ from .const import ( # noqa: F401
SCAN_INTERVAL,
SourceType,
)
from .entity import ( # noqa: F401
BaseScannerEntity,
BaseTrackerEntity,
ScannerEntity,
ScannerEntityDescription,
TrackerEntity,
TrackerEntityDescription,
)
from .legacy import ( # noqa: F401
PLATFORM_SCHEMA,
PLATFORM_SCHEMA_BASE,
@@ -59,8 +60,6 @@ from .legacy import ( # noqa: F401
see,
)
DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN)
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
"""Return the state if any or a specified device is home."""
@@ -109,23 +108,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
eager_start=True,
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up an entry."""
component: EntityComponent[BaseTrackerEntity] | None = hass.data.get(DOMAIN)
if component is not None:
return await component.async_setup_entry(entry)
component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity](
LOGGER, DOMAIN, hass
)
component.register_shutdown()
return await component.async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an entry."""
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
@@ -1,45 +1,520 @@
"""Code to set up a device tracker platform using a config entry."""
from functools import partial
import asyncio
from typing import Any, final
from homeassistant.helpers.deprecation import (
DeprecatedAlias,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
from propcache.api import cached_property
from homeassistant.components import zone
from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
STATE_HOME,
STATE_NOT_HOME,
EntityCategory,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import (
DeviceInfo,
EventDeviceRegistryUpdatedData,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.typing import StateType
from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_HOST_NAME,
ATTR_IN_ZONES,
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
SourceType,
)
from . import (
BaseTrackerEntity as _BaseTrackerEntity,
ScannerEntity as _ScannerEntity,
SourceType as _SourceType,
TrackerEntity as _TrackerEntity,
TrackerEntityDescription as _TrackerEntityDescription,
)
DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN)
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
_DEPRECATED_TrackerEntity = DeprecatedAlias(
_TrackerEntity, "homeassistant.components.device_tracker.TrackerEntity", "2027.6"
)
_DEPRECATED_ScannerEntity = DeprecatedAlias(
_ScannerEntity, "homeassistant.components.device_tracker.ScannerEntity", "2027.6"
)
_DEPRECATED_BaseTrackerEntity = DeprecatedAlias(
_BaseTrackerEntity,
"homeassistant.components.device_tracker.BaseTrackerEntity",
"2027.6",
)
_DEPRECATED_TrackerEntityDescription = DeprecatedAlias(
_TrackerEntityDescription,
"homeassistant.components.device_tracker.TrackerEntityDescription",
"2027.6",
)
_DEPRECATED_SourceType = DeprecatedAlias(
_SourceType, "homeassistant.components.device_tracker.SourceType", "2027.6"
)
# mypy: disallow-any-generics
# These can be removed if no deprecated aliases are in this module anymore
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up an entry."""
component: EntityComponent[BaseTrackerEntity] | None = hass.data.get(DOMAIN)
if component is not None:
return await component.async_setup_entry(entry)
component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity](
LOGGER, DOMAIN, hass
)
component.register_shutdown()
return await component.async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an entry."""
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
@callback
def _async_connected_device_registered(
hass: HomeAssistant, mac: str, ip_address: str | None, hostname: str | None
) -> None:
"""Register a newly seen connected device.
This is currently used by the dhcp integration
to listen for newly registered connected devices
for discovery.
"""
async_dispatcher_send(
hass,
CONNECTED_DEVICE_REGISTERED,
{
ATTR_IP: ip_address,
ATTR_MAC: mac,
ATTR_HOST_NAME: hostname,
},
)
@callback
def _async_register_mac(
hass: HomeAssistant,
domain: str,
mac: str,
unique_id: str,
) -> None:
"""Register a mac address with a unique ID."""
mac = dr.format_mac(mac)
if DATA_KEY in hass.data:
hass.data[DATA_KEY][mac] = (domain, unique_id)
return
# Setup listening.
# dict mapping mac -> partial unique ID
data = hass.data[DATA_KEY] = {mac: (domain, unique_id)}
@callback
def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None:
"""Enable the online status entity for the mac of a newly created device."""
# Only for new devices
if ev.data["action"] != "create":
return
dev_reg = dr.async_get(hass)
device_entry = dev_reg.async_get(ev.data["device_id"])
if device_entry is None:
# This should not happen, since the device was just created.
return
# Check if device has a mac
mac = None
for conn in device_entry.connections:
if conn[0] == dr.CONNECTION_NETWORK_MAC:
mac = conn[1]
break
if mac is None:
return
# Check if we have an entity for this mac
if (unique_id := data.get(mac)) is None:
return
ent_reg = er.async_get(hass)
if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None:
return
entity_entry = ent_reg.entities[entity_id]
# Make sure entity has a config entry and was disabled by the
# default disable logic in the integration and new entities
# are allowed to be added.
if (
entity_entry.config_entry_id is None
or (
(
config_entry := hass.config_entries.async_get_entry(
entity_entry.config_entry_id
)
)
is not None
and config_entry.pref_disable_new_entities
)
or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION
):
return
# Enable entity
ent_reg.async_update_entity(entity_id, disabled_by=None)
hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event)
class BaseTrackerEntity(Entity):
"""Represent a tracked device.
Not intended to be directly inherited by integrations. Integrations should
inherit TrackerEntity, BaseScannerEntity or ScannerEntity instead.
"""
_attr_device_info: None = None
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_source_type: SourceType
@cached_property
def battery_level(self) -> int | None:
"""Return the battery level of the device.
Percentage from 0-100.
"""
return None
@property
def source_type(self) -> SourceType:
"""Return the source type, eg gps or router, of the device."""
if hasattr(self, "_attr_source_type"):
return self._attr_source_type
raise NotImplementedError
@property
def state_attributes(self) -> dict[str, StateType]:
"""Return the device state attributes."""
attr: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type}
if self.battery_level is not None:
attr[ATTR_BATTERY_LEVEL] = self.battery_level
return attr
class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes tracker entities."""
CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
"in_zones",
"latitude",
"location_accuracy",
"location_name",
"longitude",
}
class TrackerEntity(
BaseTrackerEntity, cached_properties=CACHED_TRACKER_PROPERTIES_WITH_ATTR_
):
"""Base class for a tracked device."""
entity_description: TrackerEntityDescription
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
_attr_location_name: str | None = None
_attr_longitude: float | None = None
_attr_source_type: SourceType = SourceType.GPS
__active_zone: State | None = None
__in_zones: list[str] | None = None
@cached_property
def should_poll(self) -> bool:
"""No polling for entities that have location pushed."""
return False
@property
def force_update(self) -> bool:
"""All updates need to be written to the state machine if we're not polling."""
return not self.should_poll
@cached_property
def in_zones(self) -> list[str] | None:
"""Return the entity_id of zones the device is currently in.
The list may be in any order; the base class sorts it by zone radius
and discards zones which do not exist. Ignored if latitude and
longitude are both set.
"""
return self._attr_in_zones
@cached_property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device.
Value in meters.
"""
return self._attr_location_accuracy
@cached_property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device."""
return self._attr_location_name
@cached_property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
return self._attr_latitude
@cached_property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
return self._attr_longitude
@callback
def _async_write_ha_state(self) -> None:
"""Calculate active zones."""
if self.available and self.latitude is not None and self.longitude is not None:
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
elif (zones := self.in_zones) is not None:
zone_states = sorted(
(
zone_state
for entity_id in zones
if (zone_state := self.hass.states.get(entity_id)) is not None
),
key=lambda z: z.attributes[ATTR_RADIUS],
)
self.__active_zone = next(
(z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)),
None,
)
self.__in_zones = [z.entity_id for z in zone_states]
else:
self.__active_zone = None
self.__in_zones = None
super()._async_write_ha_state()
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.location_name is not None:
return self.location_name
if (
self.latitude is not None and self.longitude is not None
) or self.__in_zones is not None:
zone_state = self.__active_zone
if zone_state is None:
state = STATE_NOT_HOME
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
state = STATE_HOME
else:
state = zone_state.name
return state
return None
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []}
attr.update(super().state_attributes)
if self.latitude is not None and self.longitude is not None:
attr[ATTR_LATITUDE] = self.latitude
attr[ATTR_LONGITUDE] = self.longitude
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
return attr
class BaseScannerEntity(BaseTrackerEntity):
"""Base class for a tracked device that can be connected or disconnected.
Unlike ScannerEntity, this entity does not make assumptions about MAC
addresses being used to identify the device.
"""
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.is_connected is None:
return None
if self.is_connected:
return STATE_HOME
return STATE_NOT_HOME
@property
def is_connected(self) -> bool | None:
"""Return true if the device is connected."""
raise NotImplementedError
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
attr.update(super().state_attributes)
if not self.is_connected:
return attr
attr[ATTR_IN_ZONES] = [
zone.ENTITY_ID_HOME,
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
]
return attr
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes tracker entities."""
CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = {
"ip_address",
"mac_address",
"hostname",
}
class ScannerEntity(
BaseScannerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
):
"""Base class for a tracked device that is on a scanned network."""
entity_description: ScannerEntityDescription
_attr_hostname: str | None = None
_attr_ip_address: str | None = None
_attr_mac_address: str | None = None
_attr_source_type: SourceType = SourceType.ROUTER
@cached_property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""
return self._attr_ip_address
@cached_property
def mac_address(self) -> str | None:
"""Return the mac address of the device."""
return self._attr_mac_address
@cached_property
def hostname(self) -> str | None:
"""Return hostname of the device."""
return self._attr_hostname
@property
def unique_id(self) -> str | None:
"""Return unique ID of the entity."""
return self.mac_address
@final
@property
def device_info(self) -> DeviceInfo | None:
"""Device tracker entities should not create device registry entries."""
return None
@property
def entity_registry_enabled_default(self) -> bool:
"""Return if entity is enabled by default."""
# If mac_address is None, we can never find a device entry.
return (
# Do not disable if we won't activate our attach to device logic
self.mac_address is None
or self.device_info is not None
# Disable if we automatically attach but there is no device
or self.find_device_entry() is not None
)
@callback
def add_to_platform_start(
self,
hass: HomeAssistant,
platform: EntityPlatform,
parallel_updates: asyncio.Semaphore | None,
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
if self.mac_address and self.unique_id:
_async_register_mac(
hass,
platform.platform_name,
self.mac_address,
self.unique_id,
)
if self.is_connected and self.ip_address:
_async_connected_device_registered(
hass,
self.mac_address,
self.ip_address,
self.hostname,
)
@callback
def find_device_entry(self) -> dr.DeviceEntry | None:
"""Return device entry."""
assert self.mac_address is not None
return dr.async_get(self.hass).async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
)
async def async_internal_added_to_hass(self) -> None:
"""Handle added to Home Assistant."""
# Entities without a unique ID don't have a device
if (
not self.registry_entry
or not self.platform.config_entry
or not self.mac_address
or (device_entry := self.find_device_entry()) is None
# Entities should not have a device info. We opt them out
# of this logic if they do.
or self.device_info
):
if self.device_info:
LOGGER.debug("Entity %s unexpectedly has a device info", self.entity_id)
await super().async_internal_added_to_hass()
return
# Attach entry to device
if self.registry_entry.device_id != device_entry.id:
self.registry_entry = er.async_get(self.hass).async_update_entity(
self.entity_id, device_id=device_entry.id
)
# Attach device to config entry
if self.platform.config_entry.entry_id not in device_entry.config_entries:
dr.async_get(self.hass).async_update_device(
device_entry.id,
add_config_entry_id=self.platform.config_entry.entry_id,
)
# Do this last or else the entity registry update listener has been installed
await super().async_internal_added_to_hass()
# BaseScannerEntity.state_attributes is @final to keep external subclasses
# from tampering with it; ScannerEntity is an in-tree subclass that
# intentionally extends it with ip/mac/hostname.
@final # type: ignore[misc]
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr = super().state_attributes
if ip_address := self.ip_address:
attr[ATTR_IP] = ip_address
if (mac_address := self.mac_address) is not None:
attr[ATTR_MAC] = mac_address
if (hostname := self.hostname) is not None:
attr[ATTR_HOST_NAME] = hostname
return attr
@@ -1,494 +0,0 @@
"""Provide functionality to keep track of devices."""
import asyncio
from typing import Any, final
from propcache.api import cached_property
from homeassistant.components import zone
from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
STATE_HOME,
STATE_NOT_HOME,
EntityCategory,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import (
DeviceInfo,
EventDeviceRegistryUpdatedData,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_HOST_NAME,
ATTR_IN_ZONES,
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
SourceType,
)
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
@callback
def _async_connected_device_registered(
hass: HomeAssistant, mac: str, ip_address: str | None, hostname: str | None
) -> None:
"""Register a newly seen connected device.
This is currently used by the dhcp integration
to listen for newly registered connected devices
for discovery.
"""
async_dispatcher_send(
hass,
CONNECTED_DEVICE_REGISTERED,
{
ATTR_IP: ip_address,
ATTR_MAC: mac,
ATTR_HOST_NAME: hostname,
},
)
@callback
def _async_register_mac(
hass: HomeAssistant,
domain: str,
mac: str,
unique_id: str,
) -> None:
"""Register a mac address with a unique ID."""
mac = dr.format_mac(mac)
if DATA_KEY in hass.data:
hass.data[DATA_KEY][mac] = (domain, unique_id)
return
# Setup listening.
# dict mapping mac -> partial unique ID
data = hass.data[DATA_KEY] = {mac: (domain, unique_id)}
@callback
def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None:
"""Enable the online status entity for the mac of a newly created device."""
# Only for new devices
if ev.data["action"] != "create":
return
dev_reg = dr.async_get(hass)
device_entry = dev_reg.async_get(ev.data["device_id"])
if device_entry is None:
# This should not happen, since the device was just created.
return
# Check if device has a mac
mac = None
for conn in device_entry.connections:
if conn[0] == dr.CONNECTION_NETWORK_MAC:
mac = conn[1]
break
if mac is None:
return
# Check if we have an entity for this mac
if (unique_id := data.get(mac)) is None:
return
ent_reg = er.async_get(hass)
if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None:
return
entity_entry = ent_reg.entities[entity_id]
# Make sure entity has a config entry and was disabled by the
# default disable logic in the integration and new entities
# are allowed to be added.
if (
entity_entry.config_entry_id is None
or (
(
config_entry := hass.config_entries.async_get_entry(
entity_entry.config_entry_id
)
)
is not None
and config_entry.pref_disable_new_entities
)
or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION
):
return
# Enable entity
ent_reg.async_update_entity(entity_id, disabled_by=None)
hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event)
class BaseTrackerEntity(Entity):
"""Represent a tracked device.
Not intended to be directly inherited by integrations. Integrations should
inherit TrackerEntity, BaseScannerEntity or ScannerEntity instead.
"""
_attr_device_info: None = None
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_source_type: SourceType
@cached_property
def battery_level(self) -> int | None:
"""Return the battery level of the device.
Percentage from 0-100.
"""
return None
@property
def source_type(self) -> SourceType:
"""Return the source type, eg gps or router, of the device."""
if hasattr(self, "_attr_source_type"):
return self._attr_source_type
raise NotImplementedError
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_SOURCE_TYPE: self.source_type}
if self.battery_level is not None:
attr[ATTR_BATTERY_LEVEL] = self.battery_level
return attr
class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes tracker entities."""
CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
"in_zones",
"latitude",
"location_accuracy",
"location_name",
"longitude",
}
class TrackerEntity(
BaseTrackerEntity, cached_properties=CACHED_TRACKER_PROPERTIES_WITH_ATTR_
):
"""Base class for a tracked device."""
entity_description: TrackerEntityDescription
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
_attr_location_name: str | None = None
_attr_longitude: float | None = None
_attr_source_type: SourceType = SourceType.GPS
__active_zone: State | None = None
__in_zones: list[str] | None = None
@cached_property
def should_poll(self) -> bool:
"""No polling for entities that have location pushed."""
return False
@property
def force_update(self) -> bool:
"""All updates need to be written to the state machine if we're not polling."""
return not self.should_poll
@cached_property
def in_zones(self) -> list[str] | None:
"""Return the entity_id of zones the device is currently in.
The list may be in any order; the base class sorts it by zone radius
and discards zones which do not exist. Ignored if latitude and
longitude are both set.
"""
return self._attr_in_zones
@cached_property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device.
Value in meters.
"""
return self._attr_location_accuracy
@cached_property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device."""
return self._attr_location_name
@cached_property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
return self._attr_latitude
@cached_property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
return self._attr_longitude
@callback
def _async_write_ha_state(self) -> None:
"""Calculate active zones."""
if self.available and self.latitude is not None and self.longitude is not None:
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
elif (zones := self.in_zones) is not None:
zone_states = sorted(
(
zone_state
for entity_id in zones
if (zone_state := self.hass.states.get(entity_id)) is not None
),
key=lambda z: z.attributes[ATTR_RADIUS],
)
self.__active_zone = next(
(z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)),
None,
)
self.__in_zones = [z.entity_id for z in zone_states]
else:
self.__active_zone = None
self.__in_zones = None
super()._async_write_ha_state()
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.location_name is not None:
return self.location_name
if (
self.latitude is not None and self.longitude is not None
) or self.__in_zones is not None:
zone_state = self.__active_zone
if zone_state is None:
state = STATE_NOT_HOME
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
state = STATE_HOME
else:
state = zone_state.name
return state
return None
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []}
attr.update(super().state_attributes)
if self.latitude is not None and self.longitude is not None:
attr[ATTR_LATITUDE] = self.latitude
attr[ATTR_LONGITUDE] = self.longitude
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
return attr
class BaseScannerEntity(BaseTrackerEntity):
"""Base class for a tracked device that can be connected or disconnected.
Unlike ScannerEntity, this entity does not make assumptions about MAC
addresses being used to identify the device.
"""
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.is_connected is None:
return None
if self.is_connected:
return STATE_HOME
return STATE_NOT_HOME
@property
def is_connected(self) -> bool | None:
"""Return true if the device is connected."""
raise NotImplementedError
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
attr.update(super().state_attributes)
if not self.is_connected:
return attr
attr[ATTR_IN_ZONES] = [
zone.ENTITY_ID_HOME,
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
]
return attr
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes tracker entities."""
CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = {
"ip_address",
"mac_address",
"hostname",
}
class ScannerEntity(
BaseScannerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
):
"""Base class for a tracked device that is on a scanned network."""
entity_description: ScannerEntityDescription
_attr_hostname: str | None = None
_attr_ip_address: str | None = None
_attr_mac_address: str | None = None
_attr_source_type: SourceType = SourceType.ROUTER
@cached_property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""
return self._attr_ip_address
@cached_property
def mac_address(self) -> str | None:
"""Return the mac address of the device."""
return self._attr_mac_address
@cached_property
def hostname(self) -> str | None:
"""Return hostname of the device."""
return self._attr_hostname
@property
def unique_id(self) -> str | None:
"""Return unique ID of the entity."""
return self.mac_address
@final
@property
def device_info(self) -> DeviceInfo | None:
"""Device tracker entities should not create device registry entries."""
return None
@property
def entity_registry_enabled_default(self) -> bool:
"""Return if entity is enabled by default."""
# If mac_address is None, we can never find a device entry.
return (
# Do not disable if we won't activate our attach to device logic
self.mac_address is None
or self.device_info is not None
# Disable if we automatically attach but there is no device
or self.find_device_entry() is not None
)
@callback
def add_to_platform_start(
self,
hass: HomeAssistant,
platform: EntityPlatform,
parallel_updates: asyncio.Semaphore | None,
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
if self.mac_address and self.unique_id:
_async_register_mac(
hass,
platform.platform_name,
self.mac_address,
self.unique_id,
)
if self.is_connected and self.ip_address:
_async_connected_device_registered(
hass,
self.mac_address,
self.ip_address,
self.hostname,
)
@callback
def find_device_entry(self) -> dr.DeviceEntry | None:
"""Return device entry."""
assert self.mac_address is not None
return dr.async_get(self.hass).async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
)
async def async_internal_added_to_hass(self) -> None:
"""Handle added to Home Assistant."""
# Entities without a unique ID don't have a device
if (
not self.registry_entry
or not self.platform.config_entry
or not self.mac_address
or (device_entry := self.find_device_entry()) is None
# Entities should not have a device info. We opt them out
# of this logic if they do.
or self.device_info
):
if self.device_info:
LOGGER.debug("Entity %s unexpectedly has a device info", self.entity_id)
await super().async_internal_added_to_hass()
return
# Attach entry to device
if self.registry_entry.device_id != device_entry.id:
self.registry_entry = er.async_get(self.hass).async_update_entity(
self.entity_id, device_id=device_entry.id
)
# Attach device to config entry
if self.platform.config_entry.entry_id not in device_entry.config_entries:
dr.async_get(self.hass).async_update_device(
device_entry.id,
add_config_entry_id=self.platform.config_entry.entry_id,
)
# Do this last or else the entity registry update listener has been installed
await super().async_internal_added_to_hass()
# BaseScannerEntity.state_attributes is @final to keep external subclasses
# from tampering with it; ScannerEntity is an in-tree subclass that
# intentionally extends it with ip/mac/hostname.
@final # type: ignore[misc]
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr = super().state_attributes
if ip_address := self.ip_address:
attr[ATTR_IP] = ip_address
if (mac_address := self.mac_address) is not None:
attr[ATTR_MAC] = mac_address
if (hostname := self.hostname) is not None:
attr[ATTR_HOST_NAME] = hostname
return attr
@@ -31,6 +31,7 @@ class DownloaderConfigFlow(ConfigFlow, domain=DOMAIN):
else:
return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
+1 -17
View File
@@ -15,7 +15,6 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
from .validation import UnsupportedBoardError, async_get_supported_board_info
_LOGGER = logging.getLogger(__name__)
@@ -44,11 +43,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
try:
box_name, _ = await self._validate_input(discovery_info.ip)
except UnsupportedBoardError:
_LOGGER.debug(
"Unsupported Duco board discovered via DHCP at %s", discovery_info.ip
)
return self.async_abort(reason="unsupported_board")
except DucoConnectionError:
return self.async_abort(reason="cannot_connect")
except DucoError:
@@ -67,12 +61,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle zeroconf discovery."""
try:
box_name, mac = await self._validate_input(discovery_info.host)
except UnsupportedBoardError:
_LOGGER.debug(
"Unsupported Duco board discovered via zeroconf at %s",
discovery_info.host,
)
return self.async_abort(reason="unsupported_board")
except DucoConnectionError:
return self.async_abort(reason="cannot_connect")
except DucoError:
@@ -114,8 +102,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
box_name, mac = await self._validate_input(user_input[CONF_HOST])
except UnsupportedBoardError:
errors["base"] = "unsupported_board"
except DucoConnectionError:
errors["base"] = "cannot_connect"
except DucoError:
@@ -147,8 +133,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
box_name, mac = await self._validate_input(user_input[CONF_HOST])
except UnsupportedBoardError:
errors["base"] = "unsupported_board"
except DucoConnectionError:
errors["base"] = "cannot_connect"
except DucoError:
@@ -178,6 +162,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
session=async_get_clientsession(self.hass),
host=host,
)
board_info = await async_get_supported_board_info(client)
board_info = await client.async_get_board_info()
lan_info = await client.async_get_lan_info()
return board_info.box_name, lan_info.mac
+16 -18
View File
@@ -4,11 +4,7 @@ from dataclasses import dataclass
import logging
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import (
DucoConnectionError,
DucoError,
DucoResponseError,
)
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from duco_connectivity.models import BoardInfo, Node
from homeassistant.config_entries import ConfigEntry
@@ -17,7 +13,6 @@ from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
from .validation import UnsupportedBoardError, async_get_supported_board_info
_LOGGER = logging.getLogger(__name__)
@@ -57,18 +52,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
async def _async_setup(self) -> None:
"""Fetch board info once during initial setup."""
try:
self.board_info = await async_get_supported_board_info(self.client)
except UnsupportedBoardError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="unsupported_board",
) from err
except DucoResponseError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
self.board_info = await self.client.async_get_board_info()
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
@@ -86,6 +70,20 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
"""Fetch node data from the Duco box."""
try:
nodes = await self.client.async_get_nodes()
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
try:
lan_info = await self.client.async_get_lan_info()
except DucoConnectionError as err:
raise UpdateFailed(
+2 -7
View File
@@ -6,13 +6,11 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device you entered belongs to a different Duco box.",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unsupported_board": "This Duco system is not supported by this integration. The integration requires a Duco Connectivity Board running public API 2.1 or newer."
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unsupported_board": "[%key:component::duco::config::abort::unsupported_board%]"
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"discovery_confirm": {
@@ -100,9 +98,6 @@
},
"rate_limit_exceeded": {
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
},
"unsupported_board": {
"message": "[%key:component::duco::config::abort::unsupported_board%]"
}
},
"system_health": {
@@ -1,58 +0,0 @@
"""Validation helpers for supported Duco systems."""
from awesomeversion import (
AwesomeVersion,
AwesomeVersionStrategy,
AwesomeVersionStrategyException,
)
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoResponseError
from duco_connectivity.models import BoardInfo
# Newer Connectivity boards expose /info with PublicApiVersion. We use that
# endpoint to distinguish supported Connectivity hardware from older
# Communication board V1 hardware.
_MIN_PUBLIC_API_VERSION = AwesomeVersion(
"2.1", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
)
class UnsupportedBoardError(Exception):
"""Raised when the Duco system is not supported by this integration."""
def validate_board_support(board_info: BoardInfo) -> None:
"""Raise UnsupportedBoardError if the board does not meet support requirements."""
version = board_info.public_api_version
if version is None:
raise UnsupportedBoardError("Board did not report a public API version")
try:
parsed_version = AwesomeVersion(
version, ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
)
except AwesomeVersionStrategyException as err:
raise UnsupportedBoardError(
f"Board reported malformed public API version: {version}"
) from err
if parsed_version < _MIN_PUBLIC_API_VERSION:
raise UnsupportedBoardError(
"Board public API version "
f"{version} is below the supported minimum {_MIN_PUBLIC_API_VERSION}"
)
async def async_get_supported_board_info(client: DucoClient) -> BoardInfo:
"""Fetch and validate board info for a supported Duco system."""
try:
board_info = await client.async_get_board_info()
except DucoResponseError as err:
if err.status == 404:
# Duco indicated that Communication board V1 does not implement
# /info, so a 404 is enough to treat the device as unsupported.
raise UnsupportedBoardError(
"Board does not expose the /info endpoint"
) from err
raise
validate_board_support(board_info)
return board_info
+5 -5
View File
@@ -41,7 +41,7 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
self.stations = {}
for station in stations:
label = station["label"]
rlo_id = station["RLOIid"]
rloId = 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(rlo_id, list):
rlo_id = rlo_id[-1]
if isinstance(rloId, list):
rloId = rloId[-1]
full_name = label + " - " + rlo_id
self.stations[full_name] = station["stationReference"]
fullName = label + " - " + rloId
self.stations[fullName] = station["stationReference"]
if not self.stations:
return self.async_abort(reason="no_stations")
+1 -24
View File
@@ -8,16 +8,12 @@ from pyecobee import (
ECOBEE_REFRESH_TOKEN,
ECOBEE_USERNAME,
Ecobee,
EcobeeAuthFailedError,
EcobeeAuthMfaRequiredError,
EcobeeAuthUnknownError,
ExpiredTokenError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.util import Throttle
from .const import _LOGGER, CONF_REFRESH_TOKEN, PLATFORMS
@@ -106,26 +102,7 @@ class EcobeeData:
async def refresh(self) -> bool:
"""Refresh ecobee tokens and update config entry."""
_LOGGER.debug("Refreshing ecobee tokens and updating config entry")
try:
success = await self._hass.async_add_executor_job(
self.ecobee.refresh_tokens
)
except EcobeeAuthMfaRequiredError as err:
raise ConfigEntryAuthFailed(
"ecobee account requires MFA; reauthentication needed"
) from err
except EcobeeAuthFailedError as err:
if self.ecobee.config.get(ECOBEE_USERNAME):
raise ConfigEntryAuthFailed(
"ecobee rejected stored credentials"
) from err
_LOGGER.error("Ecobee rejected stored credentials: %s", err)
return False
except EcobeeAuthUnknownError:
_LOGGER.exception("Unexpected error refreshing ecobee tokens")
return False
if success:
if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens):
data = {}
if self.ecobee.config.get(ECOBEE_API_KEY):
data = {
+18 -129
View File
@@ -1,22 +1,12 @@
"""Config flow to configure ecobee."""
from collections.abc import Mapping
from typing import Any
from pyecobee import (
ECOBEE_API_KEY,
ECOBEE_PASSWORD,
ECOBEE_USERNAME,
Ecobee,
EcobeeAuthFailedError,
EcobeeAuthMfaRequiredError,
EcobeeAuthUnknownError,
MfaChallenge,
)
from pyecobee import ECOBEE_API_KEY, ECOBEE_PASSWORD, ECOBEE_USERNAME, Ecobee
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_CODE, CONF_PASSWORD, CONF_USERNAME
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
from .const import CONF_REFRESH_TOKEN, DOMAIN
@@ -28,9 +18,6 @@ _USER_SCHEMA = vol.Schema(
}
)
_MFA_SCHEMA = vol.Schema({vol.Required(CONF_CODE): str})
_REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle an ecobee config flow."""
@@ -38,15 +25,12 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_ecobee: Ecobee
_mfa_challenge: MfaChallenge | None = None
_pending_username: str | None = None
_pending_password: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
errors: dict[str, str] = {}
errors = {}
if user_input is not None:
api_key = user_input.get(CONF_API_KEY)
@@ -54,34 +38,27 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
password = user_input.get(CONF_PASSWORD)
if api_key and not (username or password):
# Use the user-supplied API key to attempt to obtain a PIN from ecobee.
self._ecobee = Ecobee(config={ECOBEE_API_KEY: api_key})
if await self.hass.async_add_executor_job(self._ecobee.request_pin):
# We have a PIN; move to the next step of the flow.
return await self.async_step_authorize()
errors["base"] = "pin_request_failed"
elif username and password and not api_key:
self._pending_username = username
self._pending_password = password
self._ecobee = Ecobee(
config={
ECOBEE_USERNAME: username,
ECOBEE_PASSWORD: password,
}
)
try:
success = await self.hass.async_add_executor_job(
self._ecobee.refresh_tokens
)
except EcobeeAuthMfaRequiredError as err:
self._mfa_challenge = err.args[0]
return await self.async_step_mfa()
except EcobeeAuthFailedError:
errors["base"] = "invalid_auth"
except EcobeeAuthUnknownError:
errors["base"] = "unknown"
else:
if success:
return self._async_create_or_update_entry()
errors["base"] = "login_failed"
if await self.hass.async_add_executor_job(self._ecobee.refresh_tokens):
config = {
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
}
return self.async_create_entry(title=DOMAIN, data=config)
errors["base"] = "login_failed"
else:
errors["base"] = "invalid_auth"
@@ -91,46 +68,16 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_mfa(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Collect an MFA OTP code and complete the login."""
assert self._mfa_challenge is not None
errors: dict[str, str] = {}
if user_input is not None:
code = user_input[CONF_CODE].strip()
if not code:
errors["base"] = "invalid_mfa_code"
else:
try:
success = await self.hass.async_add_executor_job(
self._ecobee.submit_mfa_code, self._mfa_challenge, code
)
except EcobeeAuthFailedError:
errors["base"] = "invalid_mfa_code"
except EcobeeAuthUnknownError:
errors["base"] = "unknown"
else:
if success:
return self._async_create_or_update_entry()
errors["base"] = "invalid_mfa_code"
return self.async_show_form(
step_id="mfa",
data_schema=_MFA_SCHEMA,
errors=errors,
description_placeholders={"mfa_type": self._mfa_challenge.mfa_type},
)
async def async_step_authorize(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Present the user with the PIN so that the app can be authorized on ecobee.com."""
errors: dict[str, str] = {}
"""Present the user with the PIN to authorize on ecobee.com."""
errors = {}
if user_input is not None:
# Attempt to obtain tokens from ecobee and finish the flow.
if await self.hass.async_add_executor_job(self._ecobee.request_tokens):
# Refresh token obtained; create the config entry.
config = {
CONF_API_KEY: self._ecobee.api_key,
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
@@ -146,61 +93,3 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
"auth_url": "https://www.ecobee.com/consumerportal/index.html",
},
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an ecobee authentication error."""
self._pending_username = entry_data.get(CONF_USERNAME)
self._pending_password = entry_data.get(CONF_PASSWORD)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Re-run the web login. May surface a fresh MFA challenge."""
errors: dict[str, str] = {}
if user_input is not None:
self._pending_password = user_input[CONF_PASSWORD]
self._ecobee = Ecobee(
config={
ECOBEE_USERNAME: self._pending_username,
ECOBEE_PASSWORD: self._pending_password,
}
)
try:
success = await self.hass.async_add_executor_job(
self._ecobee.refresh_tokens
)
except EcobeeAuthMfaRequiredError as err:
self._mfa_challenge = err.args[0]
return await self.async_step_mfa()
except EcobeeAuthFailedError:
errors["base"] = "invalid_auth"
except EcobeeAuthUnknownError:
errors["base"] = "unknown"
else:
if success:
return self._async_create_or_update_entry()
errors["base"] = "login_failed"
return self.async_show_form(
step_id="reauth_confirm",
data_schema=_REAUTH_SCHEMA,
errors=errors,
description_placeholders={"username": self._pending_username or ""},
)
def _async_create_or_update_entry(self) -> ConfigFlowResult:
"""Create a new entry or update the existing one on reauth."""
data = {
CONF_USERNAME: self._pending_username,
CONF_PASSWORD: self._pending_password,
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
}
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
return self.async_create_entry(title=DOMAIN, data=data)
+1 -16
View File
@@ -1,33 +1,18 @@
{
"config": {
"abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_mfa_code": "The MFA code was not accepted by ecobee; please try again.",
"login_failed": "Error authenticating with ecobee; please verify your credentials are correct.",
"pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.",
"token_request_failed": "Error requesting tokens from ecobee; please try again.",
"unknown": "[%key:common::config_flow::error::unknown%]"
"token_request_failed": "Error requesting tokens from ecobee; please try again."
},
"step": {
"authorize": {
"description": "Please authorize this app at {auth_url} with PIN code:\n\n{pin}\n\nThen, select **Submit**."
},
"mfa": {
"data": {
"code": "MFA code"
},
"description": "ecobee requires multi-factor authentication. Enter the {mfa_type} code from your authenticator app."
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"description": "Reauthenticate the ecobee account for **{username}**."
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
-1
View File
@@ -40,7 +40,6 @@ 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,9 +48,6 @@
},
"speak_word": {
"service": "mdi:message-minus"
},
"switch_output_turn_on_for": {
"service": "mdi:timer"
}
}
}
@@ -161,15 +161,3 @@ 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,16 +210,6 @@
}
},
"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 -34
View File
@@ -1,7 +1,5 @@
"""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
@@ -9,29 +7,15 @@ 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 DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.components.switch import 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,
@@ -48,15 +32,6 @@ 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."""
@@ -76,10 +51,6 @@ 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."""
@@ -108,7 +79,3 @@ 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")
@@ -52,4 +52,5 @@ class EGPSConfigFlow(ConfigFlow, domain=DOMAIN):
else:
return self.async_abort(reason="no_device")
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(step_id="user", data_schema=vol.Schema(data_schema))
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==45.3.1",
"aioesphomeapi==45.2.2",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.9.1"
],
+3 -3
View File
@@ -124,11 +124,11 @@ async def async_setup_entry(
for camera in coordinator.data:
device_category = coordinator.data[camera].get("device_category")
support_ext = coordinator.data[camera].get("supportExt")
supportExt = coordinator.data[camera].get("supportExt")
if (
device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value
and support_ext
and str(SupportExt.SupportBatteryManage.value) in support_ext
and supportExt
and str(SupportExt.SupportBatteryManage.value) in supportExt
):
entities.append(
EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE)
@@ -277,6 +277,7 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN):
# Check if there is at least one device
if not devices_name:
return self.async_abort(reason="no_devices_found")
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="pick_device",
data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}),
@@ -1,6 +1,6 @@
"""Device tracker platform for fressnapf_tracker."""
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -13,9 +13,6 @@
"free_members": {
"default": "mdi:account-outline"
},
"gift_members": {
"default": "mdi:gift-outline"
},
"latest_email": {
"default": "mdi:email-newsletter"
},
-6
View File
@@ -70,12 +70,6 @@ SENSORS: tuple[GhostSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.members.get("comped", 0),
),
GhostSensorEntityDescription(
key="gift_members",
translation_key="gift_members",
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.members.get("gift", 0),
),
# Post metrics
GhostSensorEntityDescription(
key="published_posts",
@@ -62,9 +62,6 @@
"free_members": {
"name": "Free members"
},
"gift_members": {
"name": "Gift members"
},
"latest_email": {
"name": "Latest email"
},
@@ -125,6 +125,7 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN):
CONF_DEVICE: DEVICE_NAMES[self._device_type],
CONF_IP_ADDRESS: self._ip_address,
}
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
@@ -7,30 +7,19 @@ 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, DOMAIN
from .const import CONF_REFERRER
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,10 +11,5 @@
"default": "mdi:molecule"
}
}
},
"services": {
"get_forecast": {
"service": "mdi:clock-end"
}
}
}
@@ -1,107 +0,0 @@
"""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,
)
@@ -1,15 +0,0 @@
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,27 +270,8 @@
}
},
"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"
}
}
}
+5 -5
View File
@@ -117,13 +117,13 @@ class DriveClient:
"""Get storage quota of the current user."""
res = await self._api.get_user(params={"fields": "storageQuota"})
storage_quota = res["storageQuota"]
limit = storage_quota.get("limit")
storageQuota = res["storageQuota"]
limit = storageQuota.get("limit")
return StorageQuotaData(
limit=int(limit) if limit is not None else None,
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)),
usage=int(storageQuota.get("usage", 0)),
usage_in_drive=int(storageQuota.get("usageInDrive", 0)),
usage_in_trash=int(storageQuota.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))
generate_content_config = self.create_generate_content_config()
generate_content_config.tools = tools or None
generate_content_config.system_instruction = (
generateContentConfig = self.create_generate_content_config()
generateContentConfig.tools = tools or None
generateContentConfig.system_instruction = (
prompt if supports_system_instruction else None
)
generate_content_config.automatic_function_calling = (
generateContentConfig.automatic_function_calling = (
AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None)
)
if structure:
generate_content_config.response_mime_type = "application/json"
generate_content_config.response_schema = _format_schema(
generateContentConfig.response_mime_type = "application/json"
generateContentConfig.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=generate_content_config
model=model_name, history=messages, config=generateContentConfig
)
user_message = chat_log.content[-1]
assert isinstance(user_message, conversation.UserContent)
+14 -18
View File
@@ -303,7 +303,7 @@ async def _cast_skill(call: ServiceCall) -> ServiceResponse:
) from e
else:
await coordinator.async_request_refresh()
return asdict(response.data) if call.return_response is True else None
return asdict(response.data)
async def _manage_quests(call: ServiceCall) -> ServiceResponse:
@@ -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()
@@ -353,7 +353,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse:
translation_placeholders={"reason": str(e)},
) from e
else:
return asdict(response.data) if call.return_response is True else None
return asdict(response.data)
async def _score_task(call: ServiceCall) -> ServiceResponse:
@@ -418,7 +418,7 @@ async def _score_task(call: ServiceCall) -> ServiceResponse:
) from e
else:
await coordinator.async_request_refresh()
return asdict(response.data) if call.return_response is True else None
return asdict(response.data)
async def _transformation(call: ServiceCall) -> ServiceResponse:
@@ -503,7 +503,7 @@ async def _transformation(call: ServiceCall) -> ServiceResponse:
translation_placeholders={"reason": str(e)},
) from e
else:
return asdict(response.data) if call.return_response is True else None
return asdict(response.data)
async def _get_tasks(call: ServiceCall) -> ServiceResponse:
@@ -839,11 +839,7 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa:
translation_placeholders={"reason": str(e)},
) from e
else:
return (
response.data.to_dict(omit_none=True)
if call.return_response is True
else None
)
return response.data.to_dict(omit_none=True)
@callback
@@ -863,7 +859,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
service_name,
_manage_quests,
schema=SERVICE_MANAGE_QUEST_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
supports_response=SupportsResponse.ONLY,
)
for service_name in (
@@ -877,7 +873,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
service_name,
_create_or_update_task,
schema=SERVICE_UPDATE_TASK_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
supports_response=SupportsResponse.ONLY,
)
for service_name in (
SERVICE_CREATE_DAILY,
@@ -890,7 +886,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
service_name,
_create_or_update_task,
schema=SERVICE_CREATE_TASK_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
@@ -898,7 +894,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_CAST_SKILL,
_cast_skill,
schema=SERVICE_CAST_SKILL_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
@@ -906,14 +902,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_SCORE_HABIT,
_score_task,
schema=SERVICE_SCORE_TASK_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_SCORE_REWARD,
_score_task,
schema=SERVICE_SCORE_TASK_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
@@ -921,7 +917,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_TRANSFORMATION,
_transformation,
schema=SERVICE_TRANSFORMATION_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
+17
View File
@@ -131,8 +131,12 @@ ATTR_AUTO_UPDATE = "auto_update"
ATTR_VERSION = "version"
ATTR_VERSION_LATEST = "version_latest"
ATTR_CPU_PERCENT = "cpu_percent"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCATION = "location"
ATTR_MEMORY_PERCENT = "memory_percent"
ATTR_SLUG = "slug"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_STATE = "state"
ATTR_STARTED = "started"
ATTR_URL = "url"
ATTR_REPOSITORY = "repository"
@@ -173,6 +177,19 @@ CORE_CONTAINER = "homeassistant"
SUPERVISOR_CONTAINER = "hassio_supervisor"
CONTAINER_STATS = "stats"
CONTAINER_INFO = "info"
# This is a mapping of which endpoint the key in the addon data
# is obtained from so we know which endpoint to update when the
# coordinator polls for updates.
KEY_TO_UPDATE_TYPES: dict[str, set[str]] = {
ATTR_VERSION_LATEST: {CONTAINER_INFO},
ATTR_MEMORY_PERCENT: {CONTAINER_STATS},
ATTR_CPU_PERCENT: {CONTAINER_STATS},
ATTR_VERSION: {CONTAINER_INFO},
ATTR_STATE: {CONTAINER_INFO},
}
REQUEST_REFRESH_DELAY = 10
HELP_URLS = {
+2 -1
View File
@@ -15,7 +15,7 @@ from aiohasupervisor.models import (
)
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_LOCATION, ATTR_NAME
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -43,6 +43,7 @@ from .const import (
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_SLUG,
DOMAIN,
@@ -562,7 +562,6 @@
"info": {
"agent_version": "Agent version",
"board": "Board",
"disk_life_time": "Disk lifetime",
"disk_total": "Disk total",
"disk_used": "Disk used",
"docker_version": "Docker version",
@@ -99,9 +99,6 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
os_info = get_os_info(hass)
information["board"] = os_info.get("board")
if (disk_life_time := host_info.get("disk_life_time")) is not None:
information["disk_life_time"] = f"{disk_life_time:.0f} %"
# Not using aiohasupervisor for ping call below intentionally. Given system health
# context, it seems preferable to do this check with minimal dependencies
information["supervisor_api"] = system_health.async_check_can_reach_url(
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.97", "babel==2.15.0"]
"requirements": ["holidays==0.96", "babel==2.15.0"]
}
+6 -5
View File
@@ -30,11 +30,6 @@ 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:
@@ -74,6 +69,12 @@ 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)
@@ -39,9 +39,6 @@
}
},
"switch": {
"homeegram": {
"default": "mdi:robot"
},
"manual_operation": {
"default": "mdi:hand-back-left"
},
@@ -499,9 +499,6 @@
"disarm_not_supported": {
"message": "Disarm is not supported by homee."
},
"homeegram_turn_off_not_supported": {
"message": "Turning off homeegrams is not supported."
},
"invalid_preset_mode": {
"message": "Invalid preset mode: {preset_mode}. Turning on is only supported with preset mode 'Manual'."
}
+1 -80
View File
@@ -6,7 +6,6 @@ from typing import Any
from pyHomee.const import AttributeType, NodeProfile
from pyHomee.model import HomeeAttribute, HomeeNode
from pyHomee.model_homeegram import HomeeGram
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -15,11 +14,9 @@ from homeassistant.components.switch import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, HomeeConfigEntry
from . import HomeeConfigEntry
from .const import CLIMATE_PROFILES, LIGHT_PROFILES
from .entity import HomeeEntity
from .helpers import setup_homee_platform
@@ -98,10 +95,6 @@ async def async_setup_entry(
"""Set up the switch platform for the Homee component."""
await setup_homee_platform(add_switch_entities, async_add_entities, config_entry)
async_add_entities(
HomeegramSwitch(homeegram, config_entry)
for homeegram in config_entry.runtime_data.homeegrams
)
class HomeeSwitch(HomeeEntity, SwitchEntity):
@@ -144,75 +137,3 @@ class HomeeSwitch(HomeeEntity, SwitchEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.async_set_homee_value(0)
class HomeegramSwitch(SwitchEntity):
"""Representation of a Homeegram as switch."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, homeegram: HomeeGram, entry: HomeeConfigEntry) -> None:
"""Initialize a homee Homeegram switch entity."""
self._homeegram = homeegram
self._entry = entry
self._attr_unique_id = f"{entry.unique_id}-hg-{homeegram.id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{entry.unique_id}-homeegrams")},
name="Homeegrams",
model="Homeegram Switches",
via_device=(DOMAIN, entry.runtime_data.settings.uid),
)
self._attr_translation_key = "homeegram"
self._host_connected = entry.runtime_data.connected
self._attr_name = homeegram.name
self._attr_entity_registry_enabled_default = self._is_enabled_by_default(
homeegram
)
async def async_added_to_hass(self) -> None:
"""Add the Homeegram entity to home assistant."""
self.async_on_remove(
self._homeegram.add_on_changed_listener(self._on_homeegram_updated)
)
self.async_on_remove(
self._entry.runtime_data.add_connection_listener(
self._on_connection_changed
)
)
@property
def is_on(self) -> bool:
"""Return True if homeegram is executing."""
return bool(self._homeegram.play)
@property
def available(self) -> bool:
"""Return the availability of the homeegram based on host availability."""
return bool(self._homeegram.active) and self._host_connected
async def async_turn_on(self, **kwargs: Any) -> None:
"""Trigger Homeegram on switching on."""
await self._entry.runtime_data.play_homeegram(self._homeegram.id)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turning off homeegrams is not supported."""
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="homeegram_turn_off_not_supported",
)
def _on_homeegram_updated(self, homeegram: HomeeGram) -> None:
self.async_write_ha_state()
async def _on_connection_changed(self, connected: bool) -> None:
self._host_connected = connected
self.async_write_ha_state()
def _is_enabled_by_default(self, homeegram: HomeeGram) -> bool:
"""Return if the homeegram should be enabled by default."""
# Only enable homeegram switches by default if there is more than 1 homeegram action.
return (
sum(len(action_list) for action_list in homeegram.actions.data.values()) > 1
)
@@ -1,7 +1,5 @@
"""Support for HomematicIP Cloud binary sensor."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homematicip.base.enums import (
@@ -39,7 +37,6 @@ from homematicip.group import SecurityGroup, SecurityZoneGroup
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
@@ -80,161 +77,6 @@ SAM_DEVICE_ATTRIBUTES = {
}
def _always_exists(_device: Device) -> bool:
"""Default exists_fn: every matched device gets the entity."""
return True
@dataclass(frozen=True, kw_only=True)
class HmipBinarySensorDescription[_DeviceT: Device](BinarySensorEntityDescription):
"""Describe a simple HomematicIP binary sensor."""
value_fn: Callable[[_DeviceT], bool]
exists_fn: Callable[[_DeviceT], bool] = _always_exists
# Required: contributes to unique_id via {device.id}_{channel}_{key}. An
# implicit default would silently lean on get_channel_index()'s fallback
# and create a migration footgun.
channel: int
MOTION_SENSOR_DESCRIPTIONS: tuple[
HmipBinarySensorDescription[
MotionDetectorIndoor | MotionDetectorOutdoor | MotionDetectorPushButton
],
...,
] = (
HmipBinarySensorDescription(
key="motion",
device_class=BinarySensorDeviceClass.MOTION,
value_fn=lambda device: device.motionDetected,
channel=1,
),
)
PRESENCE_SENSOR_DESCRIPTIONS: tuple[
HmipBinarySensorDescription[PresenceDetectorIndoor],
...,
] = (
HmipBinarySensorDescription(
key="presence",
device_class=BinarySensorDeviceClass.PRESENCE,
value_fn=lambda device: device.presenceDetected,
channel=1,
),
)
SMOKE_SENSOR_DESCRIPTIONS: tuple[
HmipBinarySensorDescription[SmokeDetector],
...,
] = (
HmipBinarySensorDescription(
key="smoke",
device_class=BinarySensorDeviceClass.SMOKE,
value_fn=lambda device: (
device.smokeDetectorAlarmType == SmokeDetectorAlarmType.PRIMARY_ALARM
),
channel=1,
),
HmipBinarySensorDescription(
key="chamber_degraded",
translation_key="chamber_degraded",
device_class=BinarySensorDeviceClass.PROBLEM,
value_fn=lambda device: device.chamberDegraded,
exists_fn=lambda device: smoke_detector_channel_data_exists(
device, "chamberDegraded"
),
channel=1,
),
)
WATER_SENSOR_DESCRIPTIONS: tuple[
HmipBinarySensorDescription[WaterSensor],
...,
] = (
HmipBinarySensorDescription(
key="water",
device_class=BinarySensorDeviceClass.MOISTURE,
value_fn=lambda device: device.moistureDetected or device.waterlevelDetected,
channel=1,
),
)
RAIN_SENSOR_DESCRIPTIONS: tuple[
HmipBinarySensorDescription[RainSensor | WeatherSensorPlus | WeatherSensorPro],
...,
] = (
HmipBinarySensorDescription(
key="rain",
translation_key="raining",
device_class=BinarySensorDeviceClass.MOISTURE,
value_fn=lambda device: device.raining,
channel=1,
),
)
MAINS_FAILURE_SENSOR_DESCRIPTIONS: tuple[
HmipBinarySensorDescription[PluggableMainsFailureSurveillance],
...,
] = (
HmipBinarySensorDescription(
key="mains_failure",
device_class=BinarySensorDeviceClass.POWER,
value_fn=lambda device: not device.powerMainsFailure,
channel=1,
),
)
BATTERY_SENSOR_DESCRIPTION = HmipBinarySensorDescription[Device](
key="battery",
device_class=BinarySensorDeviceClass.BATTERY,
value_fn=lambda device: bool(device.lowBat),
channel=0,
)
SIMPLE_BINARY_SENSOR_DESCRIPTIONS: dict[
tuple[type, ...], tuple[HmipBinarySensorDescription[Any], ...]
] = {
(
MotionDetectorIndoor,
MotionDetectorOutdoor,
MotionDetectorPushButton,
): MOTION_SENSOR_DESCRIPTIONS,
(PresenceDetectorIndoor,): PRESENCE_SENSOR_DESCRIPTIONS,
(SmokeDetector,): SMOKE_SENSOR_DESCRIPTIONS,
(WaterSensor,): WATER_SENSOR_DESCRIPTIONS,
(RainSensor, WeatherSensorPlus, WeatherSensorPro): RAIN_SENSOR_DESCRIPTIONS,
(PluggableMainsFailureSurveillance,): MAINS_FAILURE_SENSOR_DESCRIPTIONS,
}
def _create_simple_binary_sensors(
hap: HomematicipHAP,
device: Device,
) -> list[HomematicipSimpleBinarySensor[Any]]:
"""Create all simple described binary sensors for a device."""
entities: list[HomematicipSimpleBinarySensor[Any]] = []
for device_types, descriptions in SIMPLE_BINARY_SENSOR_DESCRIPTIONS.items():
if not isinstance(device, device_types):
continue
entities.extend(
HomematicipSimpleBinarySensor(hap, device, description)
for description in descriptions
if description.exists_fn(device)
)
# Each device class matches at most one group key (enforced by
# test_simple_binary_sensor_descriptions_no_overlap), so further
# iteration cannot add entities.
break
if device.lowBat is not None:
entities.append(
HomematicipSimpleBinarySensor(hap, device, BATTERY_SENSOR_DESCRIPTION)
)
return entities
def _is_full_flush_lock_controller(device: object) -> bool:
"""Return whether the device is an HmIP-FLC."""
return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr(
@@ -294,15 +136,37 @@ async def async_setup_entry(
entities.append(HomematicipShutterContact(hap, device))
if isinstance(device, RotaryHandleSensor):
entities.append(HomematicipShutterContact(hap, device, True))
if isinstance(device, Device):
entities.extend(_create_simple_binary_sensors(hap, device))
if isinstance(
device,
(
MotionDetectorIndoor,
MotionDetectorOutdoor,
MotionDetectorPushButton,
),
):
entities.append(HomematicipMotionDetector(hap, device))
if isinstance(device, PluggableMainsFailureSurveillance):
entities.append(
HomematicipPluggableMainsFailureSurveillanceSensor(hap, device)
)
if _is_full_flush_lock_controller(device):
entities.append(HomematicipFullFlushLockControllerLocked(hap, device))
entities.append(HomematicipFullFlushLockControllerGlassBreak(hap, device))
if isinstance(device, PresenceDetectorIndoor):
entities.append(HomematicipPresenceDetector(hap, device))
if isinstance(device, SmokeDetector):
entities.append(HomematicipSmokeDetector(hap, device))
if smoke_detector_channel_data_exists(device, "chamberDegraded"):
entities.append(HomematicipSmokeDetectorChamberDegraded(hap, device))
if isinstance(device, WaterSensor):
entities.append(HomematicipWaterDetector(hap, device))
if isinstance(device, (RainSensor, WeatherSensorPlus, WeatherSensorPro)):
entities.append(HomematicipRainSensor(hap, device))
if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)):
entities.append(HomematicipStormSensor(hap, device))
entities.append(HomematicipSunshineSensor(hap, device))
if isinstance(device, Device) and device.lowBat is not None:
entities.append(HomematicipBatterySensor(hap, device))
for group in hap.home.groups:
if isinstance(group, SecurityGroup):
@@ -313,35 +177,6 @@ async def async_setup_entry(
async_add_entities(entities)
class HomematicipSimpleBinarySensor[_DeviceT: Device](
HomematicipGenericEntity, BinarySensorEntity
):
"""A simple HomematicIP binary sensor backed by an entity description."""
entity_description: HmipBinarySensorDescription[_DeviceT]
def __init__(
self,
hap: HomematicipHAP,
device: _DeviceT,
description: HmipBinarySensorDescription[_DeviceT],
) -> None:
"""Initialize the described binary sensor."""
super().__init__(
hap,
device,
channel=description.channel,
feature_id=description.key,
use_description_name=True,
)
self.entity_description = description
@property
def is_on(self) -> bool:
"""Return whether the binary sensor is on."""
return self.entity_description.value_fn(self._device)
class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP cloud connection sensor."""
@@ -491,6 +326,21 @@ class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEn
return state_attr
class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP motion detector."""
_attr_device_class = BinarySensorDeviceClass.MOTION
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the motion detector."""
super().__init__(hap, device, feature_id="motion")
@property
def is_on(self) -> bool:
"""Return true if motion is detected."""
return self._device.motionDetected
class HomematicipFullFlushLockControllerLocked(
HomematicipGenericEntity, BinarySensorEntity
):
@@ -563,6 +413,75 @@ class HomematicipFullFlushLockControllerGlassBreak(
return bool(getattr(channel, "glassBroken", False))
class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP presence detector."""
_attr_device_class = BinarySensorDeviceClass.PRESENCE
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the presence detector."""
super().__init__(hap, device, feature_id="presence")
@property
def is_on(self) -> bool:
"""Return true if presence is detected."""
return self._device.presenceDetected
class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP smoke detector."""
_attr_device_class = BinarySensorDeviceClass.SMOKE
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the smoke detector."""
super().__init__(hap, device, feature_id="smoke")
@property
def is_on(self) -> bool:
"""Return true if smoke is detected."""
if self._device.smokeDetectorAlarmType:
return (
self._device.smokeDetectorAlarmType
== SmokeDetectorAlarmType.PRIMARY_ALARM
)
return False
class HomematicipSmokeDetectorChamberDegraded(
HomematicipGenericEntity, BinarySensorEntity
):
"""Representation of the HomematicIP smoke detector chamber health."""
_attr_device_class = BinarySensorDeviceClass.PROBLEM
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize smoke detector chamber health sensor."""
super().__init__(
hap, device, post="Chamber Degraded", feature_id="chamber_degraded"
)
@property
def is_on(self) -> bool:
"""Return true if smoke chamber is degraded."""
return self._device.chamberDegraded
class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP water detector."""
_attr_device_class = BinarySensorDeviceClass.MOISTURE
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the water detector."""
super().__init__(hap, device, feature_id="water")
@property
def is_on(self) -> bool:
"""Return true, if moisture or waterlevel is detected."""
return self._device.moistureDetected or self._device.waterlevelDetected
class HomematicipStormSensor(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP storm sensor."""
@@ -581,6 +500,21 @@ class HomematicipStormSensor(HomematicipGenericEntity, BinarySensorEntity):
return self._device.storm
class HomematicipRainSensor(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP rain sensor."""
_attr_device_class = BinarySensorDeviceClass.MOISTURE
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize rain sensor."""
super().__init__(hap, device, "Raining", feature_id="rain")
@property
def is_on(self) -> bool:
"""Return true, if it is raining."""
return self._device.raining
class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP sunshine sensor."""
@@ -607,6 +541,38 @@ class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity):
return state_attr
class HomematicipBatterySensor(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP low battery sensor."""
_attr_device_class = BinarySensorDeviceClass.BATTERY
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize battery sensor."""
super().__init__(hap, device, post="Battery", channel=0, feature_id="battery")
@property
def is_on(self) -> bool:
"""Return true if battery is low."""
return self._device.lowBat
class HomematicipPluggableMainsFailureSurveillanceSensor(
HomematicipGenericEntity, BinarySensorEntity
):
"""Representation of the HomematicIP pluggable mains failure surveillance sensor."""
_attr_device_class = BinarySensorDeviceClass.POWER
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize pluggable mains failure surveillance sensor."""
super().__init__(hap, device, feature_id="mains_failure")
@property
def is_on(self) -> bool:
"""Return true if power mains fails."""
return not self._device.powerMainsFailure
class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP security zone sensor group."""
@@ -87,15 +87,8 @@ class HomematicipGenericEntity(Entity):
channel_real_index: int | None = None,
*,
feature_id: str,
use_description_name: bool = False,
) -> None:
"""Initialize the generic entity.
When ``use_description_name`` is True, leave ``_attr_name`` unset so
HA's standard name resolution (``EntityDescription.name``,
``device_class``, ``translation_key`` + placeholders) drives the
entity name. Default False keeps the legacy channel/post composition.
"""
"""Initialize the generic entity."""
self._hap = hap
self._home: AsyncHome = hap.home
self._device = device
@@ -125,7 +118,7 @@ class HomematicipGenericEntity(Entity):
# Legacy mode (groups, special entities): compose the full name
# including device/group label and home prefix.
self._attr_name = self._compute_legacy_name()
elif not use_description_name:
else:
self._setup_entity_name()
@property
@@ -2,9 +2,9 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from homematicip.base.channel_event import ChannelEvent
from homematicip.base.enums import FunctionalChannelType
from homematicip.base.functionalChannels import FunctionalChannel
from homematicip.device import Device
@@ -24,41 +24,20 @@ from .hap import HomematicIPConfigEntry, HomematicipHAP
class HmipEventEntityDescription(EventEntityDescription):
"""Description of a HomematicIP Cloud event."""
event_type_map: dict[str, str]
channel_selector_fn: Callable[[FunctionalChannel], bool]
is_multi_channel: bool = False
channel_event_types: list[str] | None = None
channel_selector_fn: Callable[[FunctionalChannel], bool] | None = None
EVENT_DESCRIPTIONS: tuple[HmipEventEntityDescription, ...] = (
HmipEventEntityDescription(
EVENT_DESCRIPTIONS = {
"doorbell": HmipEventEntityDescription(
key="doorbell",
translation_key="doorbell",
device_class=EventDeviceClass.DOORBELL,
event_types=["ring"],
event_type_map={"DOOR_BELL_SENSOR_EVENT": "ring"},
channel_event_types=["DOOR_BELL_SENSOR_EVENT"],
channel_selector_fn=lambda channel: channel.channelRole == "DOOR_BELL_INPUT",
),
# Button event types follow the standard names proposed in
# home-assistant/architecture#1377: short_release, long_press,
# long_release. HmIP doesn't expose a separate press-down ("initial_press")
# event for short presses; KEY_PRESS_LONG_START is mapped to long_press
# (no separate initial_press fires for the hold sequence either).
HmipEventEntityDescription(
key="button",
translation_key="button",
device_class=EventDeviceClass.BUTTON,
event_types=["short_release", "long_press", "long_release"],
event_type_map={
"KEY_PRESS_SHORT": "short_release",
"KEY_PRESS_LONG_START": "long_press",
"KEY_PRESS_LONG_STOP": "long_release",
},
channel_selector_fn=lambda channel: (
channel.functionalChannelType == FunctionalChannelType.SINGLE_KEY_CHANNEL
),
is_multi_channel=True,
),
)
}
async def async_setup_entry(
@@ -66,43 +45,49 @@ async def async_setup_entry(
config_entry: HomematicIPConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HomematicIP events from a config entry."""
"""Set up the HomematicIP cover from a config entry."""
hap = config_entry.runtime_data
entities: list[HomematicipGenericEntity] = []
async_add_entities(
HomematicipChannelEvent(hap, device, channel, description)
for description in EVENT_DESCRIPTIONS
entities.extend(
HomematicipDoorBellEvent(
hap,
device,
channel.index,
description,
)
for description in EVENT_DESCRIPTIONS.values()
for device in hap.home.devices
for channel in device.functionalChannels
if description.channel_selector_fn(channel)
if description.channel_selector_fn and description.channel_selector_fn(channel)
)
async_add_entities(entities)
class HomematicipChannelEvent(HomematicipGenericEntity, EventEntity):
"""Event entity backed by a HomematicIP functional channel."""
class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
"""Event class for HomematicIP doorbell events."""
_attr_device_class = EventDeviceClass.DOORBELL
entity_description: HmipEventEntityDescription
def __init__(
self,
hap: HomematicipHAP,
device: Device,
channel: FunctionalChannel,
channel: int,
description: HmipEventEntityDescription,
) -> None:
"""Initialize the channel-backed event entity."""
"""Initialize the event."""
super().__init__(
hap,
device,
channel=channel.index,
channel_real_index=channel.index if description.is_multi_channel else None,
is_multi_channel=description.is_multi_channel,
feature_id=description.key,
use_description_name=description.is_multi_channel,
channel=channel,
is_multi_channel=False,
feature_id="doorbell",
)
self.entity_description = description
if description.is_multi_channel:
self._attr_translation_placeholders = {"channel": str(channel.index)}
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@@ -114,15 +99,24 @@ class HomematicipChannelEvent(HomematicipGenericEntity, EventEntity):
@callback
def _async_handle_event(self, *args, **kwargs) -> None:
"""Handle the event fired by the functional channel."""
raw_channel_event_type = self._get_channel_event_from_args(*args)
public_event = self.entity_description.event_type_map.get(
raw_channel_event_type
)
if public_event is None:
raised_channel_event = self._get_channel_event_from_args(*args)
if not self._should_raise(raised_channel_event):
return
self._trigger_event(event_type=public_event)
event_types = self.entity_description.event_types
if TYPE_CHECKING:
assert event_types is not None
self._trigger_event(event_type=event_types[0])
self.async_write_ha_state()
def _should_raise(self, event_type: str) -> bool:
"""Check if the event should be raised."""
if self.entity_description.channel_event_types is None:
return False
return event_type in self.entity_description.channel_event_types
def _get_channel_event_from_args(self, *args) -> str:
"""Get the channel event."""
if isinstance(args[0], ChannelEvent):
@@ -38,28 +38,8 @@
},
"entity": {
"binary_sensor": {
"chamber_degraded": {
"name": "Chamber degraded"
},
"cloud_connection": {
"name": "Cloud connection"
},
"raining": {
"name": "Raining"
}
},
"event": {
"button": {
"name": "Button {channel}",
"state_attributes": {
"event_type": {
"state": {
"long_press": "Long press",
"long_release": "Long release",
"short_release": "Short release"
}
}
}
}
},
"light": {
@@ -6,7 +6,6 @@ import logging
from homeassistant.const import Platform
DOMAIN = "homewizard"
ISSUE_BATTERY_MODE_CLOUD_DISABLED = "battery_mode_cloud_disabled"
PLATFORMS = [
Platform.BUTTON,
Platform.NUMBER,
@@ -23,8 +22,3 @@ CONF_PRODUCT_TYPE = "product_type"
CONF_SERIAL = "serial"
UPDATE_INTERVAL = timedelta(seconds=5)
def battery_mode_cloud_issue_id(entry_id: str) -> str:
"""Build issue id for battery mode/cloud incompatibility."""
return f"{ISSUE_BATTERY_MODE_CLOUD_DISABLED}_{entry_id}"
@@ -2,21 +2,14 @@
from homewizard_energy import HomeWizardEnergy
from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError
from homewizard_energy.models import Batteries, CombinedModels as DeviceResponseEntry
from homewizard_energy.models import CombinedModels as DeviceResponseEntry
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
DOMAIN,
ISSUE_BATTERY_MODE_CLOUD_DISABLED,
LOGGER,
UPDATE_INTERVAL,
battery_mode_cloud_issue_id,
)
from .const import DOMAIN, LOGGER, UPDATE_INTERVAL
type HomeWizardConfigEntry = ConfigEntry[HWEnergyDeviceUpdateCoordinator]
@@ -45,34 +38,6 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
)
self.api = api
def _update_battery_mode_cloud_repair_issue(
self, data: DeviceResponseEntry
) -> None:
"""Update repair issue for incompatible battery mode and cloud state."""
battery_mode_cloud_issue_active = (
data.batteries is not None
and data.system is not None
and data.batteries.mode == Batteries.Mode.PREDICTIVE.value
and data.system.cloud_enabled is False
)
issue_id = battery_mode_cloud_issue_id(self.config_entry.entry_id)
issue_exists = (
ir.async_get(self.hass).async_get_issue(DOMAIN, issue_id) is not None
)
if battery_mode_cloud_issue_active and not issue_exists:
ir.async_create_issue(
self.hass,
DOMAIN,
issue_id,
is_fixable=True,
is_persistent=False,
translation_key=ISSUE_BATTERY_MODE_CLOUD_DISABLED,
severity=ir.IssueSeverity.WARNING,
data={"entry_id": self.config_entry.entry_id},
)
elif not battery_mode_cloud_issue_active and issue_exists:
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
async def _async_update_data(self) -> DeviceResponseEntry:
"""Fetch all device and sensor data from api."""
try:
@@ -105,7 +70,6 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
raise ConfigEntryAuthFailed from ex
self.api_disabled = False
self._update_battery_mode_cloud_repair_issue(data)
self.data = data
return data
+4 -47
View File
@@ -1,18 +1,11 @@
"""Repairs for HomeWizard integration."""
from homewizard_energy.errors import RequestError
from homeassistant.components.repairs import (
ConfirmRepairFlow,
RepairsFlow,
RepairsFlowResult,
)
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from .config_flow import async_request_token
from .const import ISSUE_BATTERY_MODE_CLOUD_DISABLED
class MigrateToV2ApiRepairFlow(RepairsFlow):
@@ -66,54 +59,18 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
return self.async_create_entry(data={})
class BatteryModeCloudDisabledRepairFlow(RepairsFlow):
"""Handler for a battery mode/cloud incompatibility fix flow."""
def __init__(self, entry: ConfigEntry) -> None:
"""Create flow."""
self.entry = entry
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
"""Handle the confirm step of a fix flow."""
errors: dict[str, str] | None = None
if user_input is not None:
coordinator = self.entry.runtime_data
try:
await coordinator.api.system(cloud_enabled=True)
except RequestError:
errors = {"base": "network_error"}
else:
await coordinator.async_refresh()
return self.async_create_entry(data={})
return self.async_show_form(step_id="confirm", errors=errors)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
if data is None or not isinstance(entry_id := data.get("entry_id"), str):
return ConfirmRepairFlow()
assert data is not None
assert isinstance(data["entry_id"], str)
if issue_id.startswith("migrate_to_v2_api_") and (
entry := hass.config_entries.async_get_entry(entry_id)
entry := hass.config_entries.async_get_entry(data["entry_id"])
):
return MigrateToV2ApiRepairFlow(entry)
if issue_id.startswith(f"{ISSUE_BATTERY_MODE_CLOUD_DISABLED}_") and (
entry := hass.config_entries.async_get_entry(entry_id)
):
return BatteryModeCloudDisabledRepairFlow(entry)
raise ValueError(f"unknown repair {issue_id}") # pragma: no cover
@@ -632,32 +632,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
has_fn=lambda data: data.measurement.cycles is not None,
value_fn=lambda data: data.measurement.cycles,
),
HomeWizardSensorEntityDescription(
key="battery_group_power_w",
translation_key="battery_group_power_w",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
entity_registry_enabled_default=False,
has_fn=lambda data: data.batteries is not None,
value_fn=lambda data: (
data.batteries.power_w if data.batteries is not None else None
),
),
HomeWizardSensorEntityDescription(
key="battery_group_target_power_w",
translation_key="battery_group_target_power_w",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
entity_registry_enabled_default=False,
has_fn=lambda data: data.batteries is not None,
value_fn=lambda data: (
data.batteries.target_power_w if data.batteries is not None else None
),
),
HomeWizardSensorEntityDescription(
key="uptime",
translation_key="uptime",
@@ -106,12 +106,6 @@
"any_power_fail_count": {
"name": "Power failures detected"
},
"battery_group_power_w": {
"name": "Battery group power"
},
"battery_group_target_power_w": {
"name": "Battery group target power"
},
"cycles": {
"name": "Battery cycles"
},
@@ -188,20 +182,6 @@
}
},
"issues": {
"battery_mode_cloud_disabled": {
"fix_flow": {
"error": {
"network_error": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"confirm": {
"description": "Smart charging strategy is enabled for your battery group, but cloud connection is disabled. These settings are not compatible, as smart charging requires cloud connectivity.\n\nSelect **Submit** to enable cloud connection.",
"title": "[%key:component::homewizard::issues::battery_mode_cloud_disabled::title%]"
}
}
},
"title": "Enable cloud connection for smart charging strategy"
},
"migrate_to_v2_api": {
"fix_flow": {
"error": {
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["python_qube_heatpump"],
"quality_scale": "bronze",
"requirements": ["python-qube-heatpump==1.11.0"]
"requirements": ["python-qube-heatpump==1.10.0"]
}
@@ -140,6 +140,7 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
if not self.discovered_bridges:
return await self.async_step_manual()
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
+5 -12
View File
@@ -35,7 +35,6 @@ from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
from .const import DOMAIN
from .coordinator import AqualinkDataUpdateCoordinator
from .entity import AqualinkEntity
from .utils import error_detail
_LOGGER = logging.getLogger(__name__)
@@ -87,7 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as aio_exception:
await aqualink.close()
raise ConfigEntryNotReady(
f"Error while attempting login: {error_detail(aio_exception)}"
f"Error while attempting login: {aio_exception}"
) from aio_exception
try:
@@ -97,11 +96,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
raise ConfigEntryAuthFailed(
"Invalid credentials for iAquaLink"
) from auth_exception
except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as svc_exception:
except AqualinkServiceException as svc_exception:
await aqualink.close()
raise ConfigEntryNotReady(
"Error while attempting to retrieve systems list: "
f"{error_detail(svc_exception)}"
f"Error while attempting to retrieve systems list: {svc_exception}"
) from svc_exception
systems_list = list(systems.values())
@@ -134,15 +132,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
raise ConfigEntryAuthFailed(
"Invalid credentials for iAquaLink"
) from auth_exception
except (
AqualinkServiceException,
TimeoutError,
httpx.HTTPError,
) as svc_exception:
except AqualinkServiceException as svc_exception:
await aqualink.close()
raise ConfigEntryNotReady(
"Error while attempting to retrieve devices list: "
f"{error_detail(svc_exception)}"
f"Error while attempting to retrieve devices list: {svc_exception}"
) from svc_exception
device_registry = dr.async_get(hass)
+3 -11
View File
@@ -74,13 +74,9 @@ class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity):
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Turn the underlying heater switch on or off."""
if hvac_mode == HVACMode.HEAT:
await await_or_reraise(
self.hass, self.coordinator.config_entry, self.dev.turn_on()
)
await await_or_reraise(self.dev.turn_on())
elif hvac_mode == HVACMode.OFF:
await await_or_reraise(
self.hass, self.coordinator.config_entry, self.dev.turn_off()
)
await await_or_reraise(self.dev.turn_off())
else:
_LOGGER.warning("Unknown operation mode: %s", hvac_mode)
@@ -102,11 +98,7 @@ class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity):
@refresh_system
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
await await_or_reraise(
self.hass,
self.coordinator.config_entry,
self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE])),
)
await await_or_reraise(self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE])))
@property
def current_temperature(self) -> float | None:
@@ -1,4 +1,4 @@
"""Config flow for iAquaLink."""
"""Config flow to configure zone component."""
from collections.abc import Mapping
from typing import Any
@@ -31,7 +31,7 @@ CREDENTIALS_DATA_SCHEMA = vol.Schema(
class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
"""iAquaLink config flow."""
"""Aqualink config flow."""
VERSION = 1
@@ -50,7 +50,7 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
pass
except AqualinkServiceUnauthorizedException:
return {"base": "invalid_auth"}
except AqualinkServiceException, TimeoutError, httpx.HTTPError:
except AqualinkServiceException, httpx.HTTPError:
return {"base": "cannot_connect"}
return {}
@@ -16,7 +16,6 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPDATE_INTERVAL_BY_SYSTEM_TYPE, UPDATE_INTERVAL_DEFAULT
from .utils import error_detail
_LOGGER = logging.getLogger(__name__)
@@ -52,10 +51,9 @@ class AqualinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
self.system.serial,
)
return
except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as err:
except (AqualinkServiceException, httpx.HTTPError) as err:
raise UpdateFailed(
"Unable to update iAquaLink system "
f"{self.system.serial}: {error_detail(err)}"
f"Unable to update iAquaLink system {self.system.serial}: {err}"
) from err
if self.system.online is not True:
raise UpdateFailed(f"iAquaLink system {self.system.serial} is offline")
+5 -15
View File
@@ -67,28 +67,18 @@ class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity):
"""
# For now I'm assuming lights support either effects or brightness.
if effect_name := kwargs.get(ATTR_EFFECT):
await await_or_reraise(
self.hass,
self.coordinator.config_entry,
self.dev.set_effect_by_name(effect_name),
)
await await_or_reraise(self.dev.set_effect_by_name(effect_name))
elif brightness := kwargs.get(ATTR_BRIGHTNESS):
# Aqualink supports percentages in 25% increments.
pct = round(brightness * 4.0 / 255) * 25
await await_or_reraise(
self.hass, self.coordinator.config_entry, self.dev.set_brightness(pct)
)
await await_or_reraise(self.dev.set_brightness(pct))
else:
await await_or_reraise(
self.hass, self.coordinator.config_entry, self.dev.turn_on()
)
await await_or_reraise(self.dev.turn_on())
@refresh_system
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
await await_or_reraise(
self.hass, self.coordinator.config_entry, self.dev.turn_off()
)
await await_or_reraise(self.dev.turn_off())
@property
def brightness(self) -> int:
@@ -96,7 +86,7 @@ class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity):
The scale needs converting between 0-100 and 0-255.
"""
return round(self.dev.brightness * 255 / 100)
return self.dev.brightness * 255 / 100
@property
def effect(self) -> str:
@@ -8,7 +8,7 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["iaqualink"],
"quality_scale": "silver",
"quality_scale": "bronze",
"requirements": ["iaqualink==0.7.0", "h2==4.3.0"],
"single_config_entry": true
}
@@ -24,7 +24,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: done
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
@@ -35,7 +35,7 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
test-coverage: todo
# Gold
devices: done

Some files were not shown because too many files have changed in this diff Show More